From 67ac0a865aa300d99fb2e88066ffc833d9c440aa Mon Sep 17 00:00:00 2001 From: Eti Ijeoma Date: Sun, 1 Dec 2024 22:22:42 +0000 Subject: [PATCH 1/4] chore: add storeKey to arrayfield to handle resources with same ids --- packages/ra-core/src/controller/list/useList.ts | 9 +++++---- packages/ra-ui-materialui/src/field/ArrayField.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 2bf8c31cf96..5e98902bf7e 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -3,7 +3,7 @@ import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import { removeEmpty } from '../../util'; import { FilterPayload, RaRecord, SortPayload } from '../../types'; -import { useResourceContext } from '../../core'; +import { ResourceContextValue, useResourceContext } from '../../core'; import usePaginationState from '../usePaginationState'; import useSortState from '../useSortState'; import { useRecordSelection } from './useRecordSelection'; @@ -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, @@ -287,7 +287,7 @@ export const useList = ( onUnselectItems: selectionModifiers.clearSelection, page, perPage, - resource: '', + resource: resource, refetch, selectedIds, setFilters, @@ -310,6 +310,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 = []; From 125477e714453d4d65f4bda0e64d0e0bd4afe5bc Mon Sep 17 00:00:00 2001 From: Eti Ijeoma Date: Sun, 1 Dec 2024 23:11:41 +0000 Subject: [PATCH 2/4] Added usage examples and explanations for the new storeKey property in ArrayField --- docs/ArrayField.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) 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. From a3e5c31c131612fc74e7bc918d924bbe5314d47e Mon Sep 17 00:00:00 2001 From: Eti Ijeoma Date: Thu, 2 Jan 2025 13:27:26 +0000 Subject: [PATCH 3/4] add logic to store access different pagination for each stores --- .../ra-core/src/controller/list/useList.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 5e98902bf7e..e25c0ba6315 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -3,7 +3,7 @@ import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import { removeEmpty } from '../../util'; import { FilterPayload, RaRecord, SortPayload } from '../../types'; -import { ResourceContextValue, useResourceContext } from '../../core'; +import { useResourceContext } from '../../core'; import usePaginationState from '../usePaginationState'; import useSortState from '../useSortState'; import { useRecordSelection } from './useRecordSelection'; @@ -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( From d852f0ce1858b9c26416185868e41896f2f226b5 Mon Sep 17 00:00:00 2001 From: Eti Ijeoma Date: Thu, 2 Jan 2025 13:28:11 +0000 Subject: [PATCH 4/4] add stories and test for useList storeKey --- .../controller/list/useList.storeKey.spec.tsx | 130 +++++++++++++++ .../list/useList.storeKey.stories.tsx | 154 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 packages/ra-core/src/controller/list/useList.storeKey.spec.tsx create mode 100644 packages/ra-core/src/controller/list/useList.storeKey.stories.tsx 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 = () => ( + + + + + + + + + +);