diff --git a/docs/ArrayField.md b/docs/ArrayField.md index 94eff1283a1..1e39720fe1e 100644 --- a/docs/ArrayField.md +++ b/docs/ArrayField.md @@ -71,12 +71,13 @@ const PostShow = () => ( ## Props -| Prop | Required | Type | Default | Description | -|------------|----------|-------------------|---------|------------------------------------------| -| `children` | Required | `ReactNode` | | The component to render the list. | -| `filter` | Optional | `object` | | The filter to apply to the list. | -| `perPage` | Optional | `number` | 1000 | The number of items to display per page. | -| `sort` | Optional | `{ field, order}` | | The sort to apply to the list. | +| Prop | Required | Type | Default | Description | +|------------|----------|-------------------|---------|----------------------------------------------------| +| `children` | Required | `ReactNode` | | The component to render the list. | +| `filter` | Optional | `object` | | The filter to apply to the list. | +| `perPage` | Optional | `number` | 1000 | The number of items to display per page. | +| `sort` | Optional | `{ field, order}` | | The sort to apply to the list. | +| `storeKey` | Optional | `string` | | The key to use to store the records selection state| `` accepts the [common field props](./Fields.md#common-field-props), except `emptyText` (use the child `empty` prop instead). @@ -217,6 +218,35 @@ By default, `` displays the items in the order they are stored in th ``` {% endraw %} +## `storeKey` + +By default, `ArrayField` stores the selection state in localStorage so users can revisit the page and find the selection preserved. The key for storing this state is based on the resource name, formatted as `${resource}.selectedIds`. + +When displaying multiple lists with the same data source, you may need to distinguish their selection states. To achieve this, assign a unique `storeKey` to each `ArrayField`. This allows each list to maintain its own selection state independently. + +In the example below, two `ArrayField` components display the same data source (`books`), but each stores its selection state under a different key (`books.selectedIds` and `custom.selectedIds`). This ensures that both components can coexist on the same page without interfering with each other's state. + +```jsx + + + + + + + + + + + + +``` + ## Using The List Context `` creates a [`ListContext`](./useListContext.md) with the field value, so you can use any of the list context values in its children. This includes callbacks to sort, filter, and select items. diff --git a/packages/ra-core/src/controller/list/useList.storeKey.spec.tsx b/packages/ra-core/src/controller/list/useList.storeKey.spec.tsx new file mode 100644 index 00000000000..dac2900c013 --- /dev/null +++ b/packages/ra-core/src/controller/list/useList.storeKey.spec.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { + render, + fireEvent, + screen, + waitFor, + act, +} from '@testing-library/react'; +import { + ListsWithoutStoreKeys, + ListsWithStoreKeys, +} from './useList.storekey.stories'; +import { TestMemoryRouter } from '../../routing'; + +beforeEach(() => { + // Clear localStorage or mock store to reset state + localStorage.clear(); +}); + +describe('useList', () => { + describe('storeKey', () => { + it('should keep distinct two lists of the same resource given different keys', async () => { + render( + + + + ); + + // Wait for the initial state of perPage to stabilize + await waitFor(() => { + const perPageValue = screen + .getByLabelText('perPage') + .getAttribute('data-value'); + expect(perPageValue).toEqual('3'); + }); + + act(() => { + fireEvent.click(screen.getByLabelText('incrementPerPage')); + }); + + await waitFor(() => { + const perPageValue = screen + .getByLabelText('perPage') + .getAttribute('data-value'); + expect(perPageValue).toEqual('4'); + }); + + // Navigate to "flop" list + act(() => { + fireEvent.click(screen.getByLabelText('flop')); + }); + + await waitFor(() => { + const perPageValue = screen + .getByLabelText('perPage') + .getAttribute('data-value'); + expect(perPageValue).toEqual('3'); + }); + }); + + it('should not use the store when storeKey is false', async () => { + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByLabelText('perPage').getAttribute('data-value') + ).toEqual('3'); + }); + + act(() => { + fireEvent.click(screen.getByLabelText('incrementPerPage')); + fireEvent.click(screen.getByLabelText('incrementPerPage')); + }); + + await waitFor(() => { + expect( + screen.getByLabelText('perPage').getAttribute('data-value') + ).toEqual('5'); + }); + + act(() => { + fireEvent.click(screen.getByLabelText('nostore')); + }); + + await waitFor(() => { + const storeKey = screen + .getByLabelText('nostore') + .getAttribute('data-value'); + expect(storeKey).toEqual(null); + }); + + expect( + screen.getByLabelText('perPage').getAttribute('data-value') + ).toEqual('3'); + + act(() => { + fireEvent.click(screen.getByLabelText('incrementPerPage')); + }); + + await waitFor(() => { + expect( + screen.getByLabelText('perPage').getAttribute('data-value') + ).toEqual('4'); + }); + + act(() => { + fireEvent.click(screen.getByLabelText('store')); + }); + // Shouldn't have changed the store list + await waitFor(() => { + const perPageValue = screen + .getByLabelText('perPage') + .getAttribute('data-value'); + expect(perPageValue).toEqual('5'); + }); + + act(() => { + fireEvent.click(screen.getByLabelText('nostore')); + }); + // Should have reset its parameters to their default + expect( + screen.getByLabelText('perPage').getAttribute('data-value') + ).toEqual('3'); + }); + }); +}); diff --git a/packages/ra-core/src/controller/list/useList.storeKey.stories.tsx b/packages/ra-core/src/controller/list/useList.storeKey.stories.tsx new file mode 100644 index 00000000000..e3759e2b319 --- /dev/null +++ b/packages/ra-core/src/controller/list/useList.storeKey.stories.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { Route } from 'react-router'; +import { Link } from 'react-router-dom'; +import fakeDataProvider from 'ra-data-fakerest'; + +import { + CoreAdminContext, + CoreAdminUI, + CustomRoutes, + Resource, +} from '../../core'; +import { localStorageStore } from '../../store'; +import { FakeBrowserDecorator } from '../../storybook/FakeBrowser'; +import { CoreLayoutProps, SortPayload } from '../../types'; +import { useList } from './useList'; + +export default { + title: 'ra-core/controller/list/useList', + decorators: [FakeBrowserDecorator], + parameters: { + initialEntries: ['/top'], + }, +}; + +const styles = { + mainContainer: { + margin: '20px 10px', + }, + ul: { + marginTop: '20px', + padding: '10px', + }, +}; + +const dataProvider = fakeDataProvider({ + posts: [ + { id: 1, title: 'Post #1', votes: 90 }, + { id: 2, title: 'Post #2', votes: 20 }, + { id: 3, title: 'Post #3', votes: 30 }, + { id: 4, title: 'Post #4', votes: 40 }, + { id: 5, title: 'Post #5', votes: 50 }, + { id: 6, title: 'Post #6', votes: 60 }, + { id: 7, title: 'Post #7', votes: 70 }, + ], +}); + +const OrderedPostList = ({ + storeKey, + sort, +}: { + storeKey: string | false; + sort?: SortPayload; +}) => { + const params = useList({ + resource: 'posts', + perPage: 3, + sort, + storeKey, + }); + + return ( +
+ + storeKey: {storeKey} + +
+ + perPage: {params.perPage} + +
+ +
    + {params.data?.map(post => ( +
  • + {post.title} - {post.votes} votes +
  • + ))} +
+
+ ); +}; + +const Layout = (props: CoreLayoutProps) => ( +
+ + Go to Top Posts + + + Go to Flop Posts + + + Go to Store List + + + Go to No-Store List + + +
+ {props.children} +
+); + +const TopPosts = ( + +); +const FlopPosts = ( + +); +const StorePosts = ( + +); +const NoStorePosts = ( + +); + +export const ListsWithStoreKeys = () => ( + + + + + + + + + +); + +export const ListsWithoutStoreKeys = () => ( + + + + + + + + + +); diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 2bf8c31cf96..e25c0ba6315 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -66,8 +66,8 @@ export const useList = ( sort: initialSort, filterCallback = (record: RecordType) => Boolean(record), } = props; - const resource = useResourceContext(props); - + const resourceFromContext = useResourceContext(props); + const resource = props.storeKey ?? resourceFromContext; const [fetchingState, setFetchingState] = useState(isFetching) as [ boolean, (isFetching: boolean) => void, @@ -91,12 +91,45 @@ export const useList = ( total: data ? data.length : undefined, })); + // Store pagination states for each storeKey + const storeKeyPaginationRef = useRef<{ + [key: string]: { page: number; perPage: number }; + }>({}); + // pagination logic const { page, setPage, perPage, setPerPage } = usePaginationState({ page: initialPage, perPage: initialPerPage, }); + useEffect(() => { + if (!resource) return; + // Check if storeKey exists in the pagination store + const currentPagination = storeKeyPaginationRef.current[resource]; + if (currentPagination) { + // Restore existing pagination state for the storeKey + if ( + page !== currentPagination.page || + perPage !== currentPagination.perPage + ) { + setPage(currentPagination.page); + setPerPage(currentPagination.perPage); + } + } else { + setPage(initialPage); + setPerPage(initialPerPage); + } + storeKeyPaginationRef.current[resource] = { page, perPage }; + }, [ + resource, + setPage, + setPerPage, + initialPage, + initialPerPage, + page, + perPage, + ]); + // sort logic const { sort, setSort: setSortState } = useSortState(initialSort); const setSort = useCallback( @@ -287,7 +320,7 @@ export const useList = ( onUnselectItems: selectionModifiers.clearSelection, page, perPage, - resource: '', + resource: resource, refetch, selectedIds, setFilters, @@ -310,6 +343,7 @@ export interface UseListOptions { perPage?: number; sort?: SortPayload; resource?: string; + storeKey?: string; filterCallback?: (record: RecordType) => boolean; } diff --git a/packages/ra-ui-materialui/src/field/ArrayField.tsx b/packages/ra-ui-materialui/src/field/ArrayField.tsx index 59b8adf9474..c0b30294e6e 100644 --- a/packages/ra-ui-materialui/src/field/ArrayField.tsx +++ b/packages/ra-ui-materialui/src/field/ArrayField.tsx @@ -79,9 +79,16 @@ const ArrayFieldImpl = < >( props: ArrayFieldProps ) => { - const { children, resource, perPage, sort, filter } = props; + const { children, resource, perPage, sort, filter, storeKey } = props; const data = useFieldValue(props) || emptyArray; - const listContext = useList({ data, resource, perPage, sort, filter }); + const listContext = useList({ + data, + resource, + perPage, + sort, + filter, + storeKey, + }); return ( {children} @@ -99,6 +106,7 @@ export interface ArrayFieldProps< perPage?: number; sort?: SortPayload; filter?: FilterPayload; + storeKey?: string; } const emptyArray = [];