From c6b65c84a80ed45cfeb4e9c753e7ae7de29798b6 Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Tue, 23 Sep 2025 12:03:04 +0300 Subject: [PATCH 01/10] WIP: feat: add namespaces to useRecordSelection.ts --- .../controller/button/useDeleteController.tsx | 2 +- .../button/useDeleteWithConfirmController.tsx | 2 +- .../src/controller/list/useListController.ts | 19 +++ .../src/controller/list/useRecordSelection.ts | 152 +++++++++++++----- .../src/controller/list/useUnselect.ts | 4 +- packages/ra-ui-materialui/src/list/List.tsx | 2 + 6 files changed, 141 insertions(+), 40 deletions(-) diff --git a/packages/ra-core/src/controller/button/useDeleteController.tsx b/packages/ra-core/src/controller/button/useDeleteController.tsx index 11e6c8f4841..b7723ce85c3 100644 --- a/packages/ra-core/src/controller/button/useDeleteController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteController.tsx @@ -94,7 +94,7 @@ export const useDeleteController = < undoable: mutationMode === 'undoable', } ); - record && unselect([record.id]); + record && unselect([record.id], true); redirect(redirectTo, resource); }, onError: (error: any) => { diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx index c45b70818ec..dc54fa46a2f 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx @@ -109,7 +109,7 @@ const useDeleteWithConfirmController = < undoable: mutationMode === 'undoable', } ); - record && unselect([record.id]); + record && unselect([record.id], true); redirect(redirectTo, resource); }, onError: error => { diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 87ddaca534e..2dbd34e3aa3 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -57,6 +57,7 @@ export const useListController = < filterDefaultValues, perPage = 10, queryOptions = {}, + selectionNamespace, sort = defaultSort, storeKey, } = props; @@ -104,6 +105,7 @@ export const useListController = < const [selectedIds, selectionModifiers] = useRecordSelection({ resource, disableSyncWithStore: storeKey === false, + namespace: selectionNamespace, }); const { @@ -440,6 +442,23 @@ export interface ListControllerProps< * ); */ storeKey?: string | false; + + /** + * The key to use to store selected items. Pass undefined to use default key. + * + * @see https://marmelab.com/react-admin/List.html#selectionStorekey + * @example + * const NewerBooks = () => ( + * + * ... + * + * ); + */ + selectionNamespace?: string; } const defaultSort = { diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index 831c68e7cbd..ce596fd3a8b 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -1,14 +1,16 @@ -import { useCallback, useMemo, useState } from 'react'; +import { SetStateAction, useMemo, useState } from 'react'; -import { useStore, useRemoveFromStore } from '../../store'; +import { useStore } from '../../store'; import { RaRecord } from '../../types'; type UseRecordSelectionWithResourceArgs = { resource: string; + namespace?: string; disableSyncWithStore?: false; }; type UseRecordSelectionWithNoStoreArgs = { resource?: string; + namespace?: string; disableSyncWithStore: true; }; @@ -20,12 +22,20 @@ export type UseRecordSelectionResult = [ RecordType['id'][], { select: (ids: RecordType['id'][]) => void; - unselect: (ids: RecordType['id'][]) => void; + unselect: ( + ids: RecordType['id'][], + fromAllNamespaces?: boolean + ) => void; toggle: (id: RecordType['id']) => void; clearSelection: () => void; }, ]; +type SelectionStore = Record< + string, + RecordType['id'][] +>; + /** * Get the list of selected items for a resource, and callbacks to change the selection * @@ -37,62 +47,132 @@ export type UseRecordSelectionResult = [ export const useRecordSelection = ( args: UseRecordSelectionArgs ): UseRecordSelectionResult => { - const { resource = '', disableSyncWithStore = false } = args; + const { + resource = '', + disableSyncWithStore = false, + namespace = defaultNamespace, + } = args; - const storeKey = `${resource}.selectedIds`; + const storeKeyNoNamespace = `${resource}.selectedIds`; + const storeKey = `${storeKeyNoNamespace}.namespaced`; - const [localIds, setLocalIds] = - useState(defaultSelection); + const [localSelectionStore, setLocalSelectionStore] = + useState>(defaultSelection); // As we can't conditionally call a hook, if the storeKey is false, // we'll ignore the params variable later on and won't call setParams either. - const [storeIds, setStoreIds] = useStore( - storeKey, - defaultSelection - ); - const resetStore = useRemoveFromStore(storeKey); + const [selectionStore, setSelectionStore] = useStore< + SelectionStore + >(storeKey, defaultSelection); + + const [selectionStoreNoNamespace, setSelectionStoreNoNamespace] = useStore< + RecordType['id'][] + >(storeKeyNoNamespace, []); - const ids = disableSyncWithStore ? localIds : storeIds; - const setIds = disableSyncWithStore ? setLocalIds : setStoreIds; + const ids = + (disableSyncWithStore + ? localSelectionStore[namespace] + : namespace === defaultNamespace + ? selectionStoreNoNamespace + : selectionStore[namespace]) ?? defaultEmptyIds; - const reset = useCallback(() => { - if (disableSyncWithStore) { - setLocalIds(defaultSelection); - } else { - resetStore(); - } - }, [disableSyncWithStore, resetStore]); + const setStore = useMemo( + () => + disableSyncWithStore + ? setLocalSelectionStore + : ((storeOrSetStateAction => { + if (typeof storeOrSetStateAction === 'function') { + const setStateAction = storeOrSetStateAction; + + setSelectionStore(setStateAction); + setSelectionStoreNoNamespace( + ids => + setStateAction({ [defaultNamespace]: ids })?.[ + defaultNamespace + ] + ); + } else { + const store = storeOrSetStateAction; + setSelectionStore(store); + setSelectionStoreNoNamespace(store[defaultNamespace]); + } + }) as React.Dispatch< + SetStateAction> + >), + [disableSyncWithStore, setSelectionStore, setSelectionStoreNoNamespace] + ); const selectionModifiers = useMemo( () => ({ - select: (idsToAdd: RecordType['id'][]) => { - if (!idsToAdd) return; - setIds([...idsToAdd]); + select: (idsToSelect: RecordType['id'][]) => { + if (!idsToSelect) return; + + setStore(store => ({ + ...store, + [namespace]: [...idsToSelect], + })); }, - unselect(idsToRemove: RecordType['id'][]) { + unselect( + idsToRemove: RecordType['id'][], + fromAllNamespaces?: boolean + ) { if (!idsToRemove || idsToRemove.length === 0) return; - setIds(ids => { - if (!Array.isArray(ids)) return []; - return ids.filter(id => !idsToRemove.includes(id)); + setStore(store => { + if (!fromAllNamespaces) { + return { + ...store, + [namespace]: store[namespace]?.filter( + id => !idsToRemove.includes(id) + ), + }; + } else { + return Object.fromEntries( + Object.entries(store).map(([namespace, ids]) => { + return [ + namespace, + ids?.filter( + id => !idsToRemove.includes(id) + ), + ]; + }) + ); + } }); }, toggle: (id: RecordType['id']) => { if (typeof id === 'undefined') return; - setIds(ids => { - if (!Array.isArray(ids)) return [...ids]; + + setStore(store => { + const ids = store[namespace]; + + if (!Array.isArray(ids)) + return { ...store, [namespace]: [...ids] }; + const index = ids.indexOf(id); - return index > -1 - ? [...ids.slice(0, index), ...ids.slice(index + 1)] - : [...ids, id]; + const hasId = index > -1; + + return { + ...store, + [namespace]: hasId + ? [...ids.slice(0, index), ...ids.slice(index + 1)] + : [...ids, id], + }; }); }, clearSelection: () => { - reset(); + setStore(store => { + return { + ...store, + [namespace]: [], + }; + }); }, }), - [setIds, reset] + [setStore, namespace] ); return [ids, selectionModifiers]; }; -const defaultSelection = []; +const defaultNamespace = ''; +const defaultSelection = {}; +const defaultEmptyIds = []; diff --git a/packages/ra-core/src/controller/list/useUnselect.ts b/packages/ra-core/src/controller/list/useUnselect.ts index ce5742eaeba..86d8ae285f8 100644 --- a/packages/ra-core/src/controller/list/useUnselect.ts +++ b/packages/ra-core/src/controller/list/useUnselect.ts @@ -16,8 +16,8 @@ export const useUnselect = (resource?: string) => { resource ? { resource } : { disableSyncWithStore: true } ); return useCallback( - (ids: Identifier[]) => { - unselect(ids); + (ids: Identifier[], fromAllNamespaces: boolean = false) => { + unselect(ids, fromAllNamespaces); }, [unselect] ); diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 03349a90424..bec28bf9793 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -72,6 +72,7 @@ export const List = ( perPage = 10, queryOptions, resource, + selectionNamespace, sort, storeKey, render, @@ -100,6 +101,7 @@ export const List = ( perPage={perPage} queryOptions={queryOptions} resource={resource} + selectionNamespace={selectionNamespace} sort={sort} storeKey={storeKey} // Disable offline support from ListBase as it is handled by ListView to keep the ListView container From d216eb8addb75d4fbc5ec2984dd253bc61318a67 Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 14:41:47 +0300 Subject: [PATCH 02/10] WIP: feat: use `storeKey` in useRecordSelection --- .../src/controller/list/useListController.ts | 20 +--- .../src/controller/list/useRecordSelection.ts | 93 +++++++------------ .../src/controller/list/useUnselect.ts | 4 +- packages/ra-ui-materialui/src/list/List.tsx | 2 - 4 files changed, 38 insertions(+), 81 deletions(-) diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 2dbd34e3aa3..8b81ba58293 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -57,7 +57,6 @@ export const useListController = < filterDefaultValues, perPage = 10, queryOptions = {}, - selectionNamespace, sort = defaultSort, storeKey, } = props; @@ -105,7 +104,7 @@ export const useListController = < const [selectedIds, selectionModifiers] = useRecordSelection({ resource, disableSyncWithStore: storeKey === false, - namespace: selectionNamespace, + storeKey: storeKey === false ? undefined : storeKey, }); const { @@ -442,23 +441,6 @@ export interface ListControllerProps< * ); */ storeKey?: string | false; - - /** - * The key to use to store selected items. Pass undefined to use default key. - * - * @see https://marmelab.com/react-admin/List.html#selectionStorekey - * @example - * const NewerBooks = () => ( - * - * ... - * - * ); - */ - selectionNamespace?: string; } const defaultSort = { diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index ce596fd3a8b..f96b3214e26 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -1,16 +1,16 @@ -import { SetStateAction, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useStore } from '../../store'; import { RaRecord } from '../../types'; type UseRecordSelectionWithResourceArgs = { resource: string; - namespace?: string; + storeKey?: string; disableSyncWithStore?: false; }; type UseRecordSelectionWithNoStoreArgs = { resource?: string; - namespace?: string; + storeKey?: string; disableSyncWithStore: true; }; @@ -22,10 +22,7 @@ export type UseRecordSelectionResult = [ RecordType['id'][], { select: (ids: RecordType['id'][]) => void; - unselect: ( - ids: RecordType['id'][], - fromAllNamespaces?: boolean - ) => void; + unselect: (ids: RecordType['id'][], fromAllStoreKeys?: boolean) => void; toggle: (id: RecordType['id']) => void; clearSelection: () => void; }, @@ -40,65 +37,45 @@ type SelectionStore = Record< * Get the list of selected items for a resource, and callbacks to change the selection * * @param args.resource The resource name, e.g. 'posts' - * @param args.disableSyncWithStore Controls the selection syncronization with the store + * @param args.storeKey The key to use to store selected items. Pass false to disable synchronization with the store. + * @param args.disableSyncWithStore Controls the selection synchronization with the store * * @returns {Object} Destructure as [selectedIds, { select, toggle, clearSelection }]. */ export const useRecordSelection = ( args: UseRecordSelectionArgs ): UseRecordSelectionResult => { - const { - resource = '', - disableSyncWithStore = false, - namespace = defaultNamespace, - } = args; - - const storeKeyNoNamespace = `${resource}.selectedIds`; - const storeKey = `${storeKeyNoNamespace}.namespaced`; - - const [localSelectionStore, setLocalSelectionStore] = - useState>(defaultSelection); - // As we can't conditionally call a hook, if the storeKey is false, - // we'll ignore the params variable later on and won't call setParams either. - const [selectionStore, setSelectionStore] = useStore< + const { resource = '', storeKey, disableSyncWithStore } = args; + + const namespace = storeKey ?? defaultNamespace; + + const finalStoreKey = `${resource}.selectedIds`; + + const [localSelectionStore, setLocalSelectionStore] = useState< + SelectionStore + >(defaultSelectionStore); + // As we can't conditionally call a hook, if the disableSyncWithStore is true, + // we'll ignore the store value later on and won't call setSelectionStore either. + const [selectionStoreUnknownVersion, setSelectionStore] = useStore< SelectionStore - >(storeKey, defaultSelection); + >(finalStoreKey, defaultSelectionStore); - const [selectionStoreNoNamespace, setSelectionStoreNoNamespace] = useStore< - RecordType['id'][] - >(storeKeyNoNamespace, []); + // Previous version saved RecordType['id'][] in store. + // Convert it to new type. + const selectionStore = Array.isArray(selectionStoreUnknownVersion) + ? { + ...defaultSelectionStore, + [defaultNamespace]: selectionStoreUnknownVersion, + } + : selectionStoreUnknownVersion; - const ids = - (disableSyncWithStore - ? localSelectionStore[namespace] - : namespace === defaultNamespace - ? selectionStoreNoNamespace - : selectionStore[namespace]) ?? defaultEmptyIds; + const store = disableSyncWithStore ? localSelectionStore : selectionStore; + const ids = store[namespace] ?? defaultEmptyIds; const setStore = useMemo( () => - disableSyncWithStore - ? setLocalSelectionStore - : ((storeOrSetStateAction => { - if (typeof storeOrSetStateAction === 'function') { - const setStateAction = storeOrSetStateAction; - - setSelectionStore(setStateAction); - setSelectionStoreNoNamespace( - ids => - setStateAction({ [defaultNamespace]: ids })?.[ - defaultNamespace - ] - ); - } else { - const store = storeOrSetStateAction; - setSelectionStore(store); - setSelectionStoreNoNamespace(store[defaultNamespace]); - } - }) as React.Dispatch< - SetStateAction> - >), - [disableSyncWithStore, setSelectionStore, setSelectionStoreNoNamespace] + disableSyncWithStore ? setLocalSelectionStore : setSelectionStore, + [disableSyncWithStore, setSelectionStore] ); const selectionModifiers = useMemo( @@ -113,11 +90,11 @@ export const useRecordSelection = ( }, unselect( idsToRemove: RecordType['id'][], - fromAllNamespaces?: boolean + fromAllStoreKeys?: boolean ) { if (!idsToRemove || idsToRemove.length === 0) return; setStore(store => { - if (!fromAllNamespaces) { + if (!fromAllStoreKeys) { return { ...store, [namespace]: store[namespace]?.filter( @@ -142,7 +119,7 @@ export const useRecordSelection = ( if (typeof id === 'undefined') return; setStore(store => { - const ids = store[namespace]; + const ids = store[namespace] ?? defaultEmptyIds; if (!Array.isArray(ids)) return { ...store, [namespace]: [...ids] }; @@ -174,5 +151,5 @@ export const useRecordSelection = ( }; const defaultNamespace = ''; -const defaultSelection = {}; +const defaultSelectionStore = {}; const defaultEmptyIds = []; diff --git a/packages/ra-core/src/controller/list/useUnselect.ts b/packages/ra-core/src/controller/list/useUnselect.ts index 86d8ae285f8..edfb3d35dea 100644 --- a/packages/ra-core/src/controller/list/useUnselect.ts +++ b/packages/ra-core/src/controller/list/useUnselect.ts @@ -16,8 +16,8 @@ export const useUnselect = (resource?: string) => { resource ? { resource } : { disableSyncWithStore: true } ); return useCallback( - (ids: Identifier[], fromAllNamespaces: boolean = false) => { - unselect(ids, fromAllNamespaces); + (ids: Identifier[], fromAllStoreKeys: boolean = false) => { + unselect(ids, fromAllStoreKeys); }, [unselect] ); diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index bec28bf9793..03349a90424 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -72,7 +72,6 @@ export const List = ( perPage = 10, queryOptions, resource, - selectionNamespace, sort, storeKey, render, @@ -101,7 +100,6 @@ export const List = ( perPage={perPage} queryOptions={queryOptions} resource={resource} - selectionNamespace={selectionNamespace} sort={sort} storeKey={storeKey} // Disable offline support from ListBase as it is handled by ListView to keep the ListView container From 7a9d8fe84c8a3b7f519031a0fa00b1fa6f7d6e47 Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 15:32:14 +0300 Subject: [PATCH 03/10] WIP: test: useRecordSelection.ts --- .../useDeleteWithConfirmController.spec.tsx | 48 +++++++++++ .../list/useRecordSelection.spec.tsx | 79 +++++++++++++++++++ .../src/controller/list/useRecordSelection.ts | 38 ++++++--- 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx index be5e46d2423..4a98e4303e0 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx @@ -11,6 +11,7 @@ import useDeleteWithConfirmController, { import { TestMemoryRouter } from '../../routing'; import { useNotificationContext } from '../../notification'; +import { memoryStore, StoreSetter } from '../../store'; describe('useDeleteWithConfirmController', () => { it('should call the dataProvider.delete() function with the meta param', async () => { @@ -101,4 +102,51 @@ describe('useDeleteWithConfirmController', () => { ]); }); }); + + it('should unselect records from all storeKeys in useRecordSelection', async () => { + const dataProvider = testDataProvider({ + delete: jest.fn((resource, params) => { + return Promise.resolve({ data: params.previousData }); + }), + }); + + const MockComponent = () => { + const { handleDelete } = useDeleteWithConfirmController({ + record: { id: 456 }, + resource: 'posts', + mutationMode: 'pessimistic', + } as UseDeleteWithConfirmControllerParams); + return ; + }; + + const store = memoryStore(); + + render( + + + + + } /> + + + + + ); + + const button = await screen.findByText('Delete'); + fireEvent.click(button); + await waitFor( + () => + expect(store.getItem('posts.selectedIds')).toEqual({ + ['']: [123], + ['bar']: [], + }), + { + timeout: 1000, + } + ); + }); }); diff --git a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx index aaa8529b464..ad0fa35e0fc 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx +++ b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx @@ -23,6 +23,26 @@ describe('useRecordSelection', () => { }); it('should use the stored value', () => { + const { result } = renderHook( + () => useRecordSelection({ resource: 'foo' }), + { + wrapper: ({ children }) => ( + + + {children} + + + ), + } + ); + const [selected] = result.current; + expect(selected).toEqual([123, 456]); + }); + + it('should use the stored value in previous format', () => { const { result } = renderHook( () => useRecordSelection({ resource: 'foo' }), { @@ -39,6 +59,31 @@ describe('useRecordSelection', () => { expect(selected).toEqual([123, 456]); }); + it('should store in a new format after any operation', async () => { + const store = memoryStore(); + const { result } = renderHook( + () => useRecordSelection({ resource: 'foo' }), + { + wrapper: ({ children }) => ( + + + {children} + + + ), + } + ); + + const [, { select }] = result.current; + select([123, 456, 7]); + await waitFor(() => { + const stored = store.getItem('foo.selectedIds'); + expect(stored).toEqual({ + ['']: [123, 456, 7], + }); + }); + }); + describe('select', () => { it('should allow to select a record', async () => { const { result } = renderHook( @@ -378,4 +423,38 @@ describe('useRecordSelection', () => { }); }); }); + describe('using storeKey', () => { + it('should return empty array by default', () => { + const { result } = renderHook( + () => + useRecordSelection({ + resource: 'foo', + storeKey: 'bar', + }), + { wrapper } + ); + const [selected] = result.current; + expect(selected).toEqual([]); + }); + + it('should use the stored value', () => { + const { result } = renderHook( + () => useRecordSelection({ resource: 'foo', storeKey: 'bar' }), + { + wrapper: ({ children }) => ( + + + {children} + + + ), + } + ); + const [selected] = result.current; + expect(selected).toEqual([123, 456]); + }); + }); }); diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index f96b3214e26..4e4ae82e385 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -60,21 +60,26 @@ export const useRecordSelection = ( SelectionStore >(finalStoreKey, defaultSelectionStore); - // Previous version saved RecordType['id'][] in store. - // Convert it to new type. - const selectionStore = Array.isArray(selectionStoreUnknownVersion) - ? { - ...defaultSelectionStore, - [defaultNamespace]: selectionStoreUnknownVersion, - } - : selectionStoreUnknownVersion; - - const store = disableSyncWithStore ? localSelectionStore : selectionStore; + const store = disableSyncWithStore + ? localSelectionStore + : migrateSelectionStoreToNewVersion(selectionStoreUnknownVersion); const ids = store[namespace] ?? defaultEmptyIds; const setStore = useMemo( () => - disableSyncWithStore ? setLocalSelectionStore : setSelectionStore, + disableSyncWithStore + ? setLocalSelectionStore + : (function migrateAndSetSelectionStore(valueOrSetter) { + if (typeof valueOrSetter === 'function') { + setSelectionStore(prevValue => + valueOrSetter( + migrateSelectionStoreToNewVersion(prevValue) + ) + ); + } else { + setSelectionStore(valueOrSetter); + } + } satisfies typeof setSelectionStore), [disableSyncWithStore, setSelectionStore] ); @@ -153,3 +158,14 @@ export const useRecordSelection = ( const defaultNamespace = ''; const defaultSelectionStore = {}; const defaultEmptyIds = []; + +function migrateSelectionStoreToNewVersion( + selectionStoreUnknownVersion: SelectionStore +) { + return Array.isArray(selectionStoreUnknownVersion) + ? { + ...defaultSelectionStore, + [defaultNamespace]: selectionStoreUnknownVersion, + } + : selectionStoreUnknownVersion; +} From 1f353753936db3833ca0bd534f30db3b9c9e1ddb Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 15:44:23 +0300 Subject: [PATCH 04/10] WIP: doc: about `storeKey` in useRecordSelection --- docs/List.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/List.md b/docs/List.md index 887942956d1..3e657699c95 100644 --- a/docs/List.md +++ b/docs/List.md @@ -486,7 +486,7 @@ const Dashboard = () => ( ) ``` -Please note that the selection state is not synced in the URL but in a global store using the resource as key. Thus, all lists in the page using the same resource will share the same synced selection state. This is a design choice because if row selection is not tied to a resource, then when a user deletes a record it may remain selected without any ability to unselect it. If you want to allow custom `storeKey`'s for managing selection state, you will have to implement your own `useListController` hook and pass a custom key to the `useRecordSelection` hook. You will then need to implement your own `DeleteButton` and `BulkDeleteButton` to manually unselect rows when deleting records. You can still opt out of all store interactions including selection if you set it to `false`. +Please note that the selection state is not synced in the URL but in a global store using the resource and, if provided, `storeKey` as part of the key. Thus, all lists in the page using the same resource and `storeKey` will share the same synced selection state. This is a design choice because if row selection is not tied to a resource, then when a user deletes a record it may remain selected without any ability to unselect it. You can still opt out of all store interactions for list state if you set it to `false`. ## `empty` @@ -1097,7 +1097,9 @@ const Admin = () => { **Tip:** The `storeKey` is actually passed to the underlying `useListController` hook, which you can use directly for more complex scenarios. See the [`useListController` doc](./useListController.md#storekey) for more info. -**Note:** *Selection state* will remain linked to a resource-based key regardless of the specified `storeKey` string. This is a design choice because if row selection is not tied to a resource, then when a user deletes a record it may remain selected without any ability to unselect it. If you want to allow custom `storeKey`'s for managing selection state, you will have to implement your own `useListController` hook and pass a custom key to the `useRecordSelection` hook. You will then need to implement your own `DeleteButton` and `BulkDeleteButton` to manually unselect rows when deleting records. You can still opt out of all store interactions including selection if you set it to `false`. +**Tip:** The `storeKey` is also passed to the underlying `useRecordSelection` hook, so that lists with different storeKeys for same resource will have independent selection states. + +**Tip:** Setting `storeKey` to `false` will opt out of all store interactions including selection. ## `title` From ab5fa00e27cabccfb1e8084023f5df0d8d4783fd Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 15:59:03 +0300 Subject: [PATCH 05/10] WIP: feat: amend useReferenceManyFieldController to pass proper resource and storeKey to useRecordSelection --- .../useReferenceManyFieldController.spec.tsx | 44 +++++++++++++++++-- .../field/useReferenceManyFieldController.ts | 4 +- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index 3537e2307b2..ce8e8506554 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -332,6 +332,42 @@ describe('useReferenceManyFieldController', () => { }); }); + it('should store selection state under default key', async () => { + const store = memoryStore(); + const setStore = jest.spyOn(store, 'setItem'); + + render( + + + {({ onToggleItem }) => { + return ( + + ); + }} + + + ); + + fireEvent.click(await screen.findByText('Toggle')); + await waitFor(() => { + expect(setStore).toHaveBeenCalledWith('books.selectedIds', { + ['authors.123']: [456], + }); + }); + }); + it('should support custom storeKey', async () => { const store = memoryStore(); const setStore = jest.spyOn(store, 'setItem'); @@ -352,7 +388,7 @@ describe('useReferenceManyFieldController', () => { > {({ onToggleItem }) => { return ( - ); @@ -363,9 +399,9 @@ describe('useReferenceManyFieldController', () => { fireEvent.click(await screen.findByText('Toggle')); await waitFor(() => { - expect(setStore).toHaveBeenCalledWith('customKey.selectedIds', [ - 123, - ]); + expect(setStore).toHaveBeenCalledWith('books.selectedIds', { + ['customKey']: [456], + }); }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index 66495665d4b..d6efbd07941 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -72,7 +72,6 @@ export const useReferenceManyFieldController = < const resource = useResourceContext(props); const dataProvider = useDataProvider(); const queryClient = useQueryClient(); - const storeKey = props.storeKey ?? `${resource}.${record?.id}.${reference}`; const { meta, ...otherQueryOptions } = queryOptions; // pagination logic @@ -93,7 +92,8 @@ export const useReferenceManyFieldController = < // selection logic const [selectedIds, selectionModifiers] = useRecordSelection({ - resource: storeKey, + resource: reference, + storeKey: props.storeKey ?? `${resource}.${record?.id}`, }); // filter logic From f5e927c7ef0dc9e1ade86dd1bb688408497725b8 Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 16:46:17 +0300 Subject: [PATCH 06/10] WIP: test: clear selection in useRecordSelection --- .../list/useRecordSelection.spec.tsx | 77 +++++++++++++++++++ .../src/controller/list/useRecordSelection.ts | 28 +++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx index ad0fa35e0fc..ba7ea1bb3da 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx +++ b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx @@ -456,5 +456,82 @@ describe('useRecordSelection', () => { const [selected] = result.current; expect(selected).toEqual([123, 456]); }); + + it('should allow to unselect from all storeKeys', async () => { + const { result } = renderHook( + () => [ + useRecordSelection({ resource: 'foo', storeKey: 'bar1' }), + useRecordSelection({ resource: 'foo', storeKey: 'bar2' }), + ], + { + wrapper, + } + ); + + const [, { toggle: toggle1 }] = result.current[0]; + const [, { toggle: toggle2 }] = result.current[1]; + toggle1(123); + await waitFor(() => {}); + toggle2(123); + await waitFor(() => {}); + toggle2(456); + await waitFor(() => { + const [selected1] = result.current[0]; + expect(selected1).toEqual([123]); + const [selected2] = result.current[1]; + expect(selected2).toEqual([123, 456]); + }); + + const [, { unselect }] = result.current[0]; + unselect([123], true); + + await waitFor(() => { + const [selected1] = result.current[0]; + expect(selected1).toEqual([]); + const [selected2] = result.current[1]; + expect(selected2).toEqual([456]); + }); + }); + + it('should allow to clear the selection from all storeKeys', async () => { + const { result } = renderHook( + () => [ + useRecordSelection({ + resource: 'foo', + storeKey: 'bar1', + }), + useRecordSelection({ + resource: 'foo', + storeKey: 'bar2', + }), + ], + { + wrapper, + } + ); + + const [, { toggle: toggle1 }] = result.current[0]; + const [, { toggle: toggle2 }] = result.current[1]; + toggle1(123); + // `set` in useStore doesn't chain set calls happened in one render cycle... + await waitFor(() => {}); + toggle2(456); + await waitFor(() => { + const [selected1] = result.current[0]; + expect(selected1).toEqual([123]); + const [selected2] = result.current[1]; + expect(selected2).toEqual([456]); + }); + + const [, { clearSelection }] = result.current[0]; + clearSelection(true); + + await waitFor(() => { + const [selected1] = result.current[0]; + expect(selected1).toEqual([]); + const [selected2] = result.current[1]; + expect(selected2).toEqual([]); + }); + }); }); }); diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index 4e4ae82e385..ce8e4a4cf3d 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -24,7 +24,7 @@ export type UseRecordSelectionResult = [ select: (ids: RecordType['id'][]) => void; unselect: (ids: RecordType['id'][], fromAllStoreKeys?: boolean) => void; toggle: (id: RecordType['id']) => void; - clearSelection: () => void; + clearSelection: (fromAllStoreKeys?: boolean) => void; }, ]; @@ -140,12 +140,28 @@ export const useRecordSelection = ( }; }); }, - clearSelection: () => { + clearSelection: (fromAllStoreKeys?: boolean) => { setStore(store => { - return { - ...store, - [namespace]: [], - }; + if (fromAllStoreKeys) { + console.log( + store, + Object.fromEntries( + Object.keys(store).map(namespace => [ + namespace, + [], + ]) + ) + ); + + return Object.fromEntries( + Object.keys(store).map(namespace => [namespace, []]) + ); + } else { + return { + ...store, + [namespace]: [], + }; + } }); }, }), From cec8a5228462fd99c62191894015c719262d67ab Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Thu, 25 Sep 2025 16:51:02 +0300 Subject: [PATCH 07/10] WIP: feat: clear selection in useBulkDeleteController --- .../ra-core/src/controller/button/useBulkDeleteController.ts | 2 +- .../controller/field/useReferenceManyFieldController.spec.tsx | 2 +- packages/ra-core/src/controller/list/useListController.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/controller/button/useBulkDeleteController.ts b/packages/ra-core/src/controller/button/useBulkDeleteController.ts index b21465114a5..cb742b57157 100644 --- a/packages/ra-core/src/controller/button/useBulkDeleteController.ts +++ b/packages/ra-core/src/controller/button/useBulkDeleteController.ts @@ -47,7 +47,7 @@ export const useBulkDeleteController = < undoable: mutationMode === 'undoable', } ); - onUnselectItems(); + onUnselectItems(true); }, onError: (error: any) => { notify( diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index ce8e8506554..f9f05e6f538 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -332,7 +332,7 @@ describe('useReferenceManyFieldController', () => { }); }); - it('should store selection state under default key', async () => { + it('should store selection state linked to referencing record', async () => { const store = memoryStore(); const setStore = jest.spyOn(store, 'setItem'); diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 8b81ba58293..820611a0b05 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -516,7 +516,7 @@ export interface ListControllerBaseResult { | UseReferenceManyFieldControllerParams['queryOptions']; }) => void; onToggleItem: (id: RecordType['id']) => void; - onUnselectItems: () => void; + onUnselectItems: (fromAllStoreKeys?: boolean) => void; page: number; perPage: number; refetch: (() => void) | UseGetListHookValue['refetch']; From 29914cf36d7ca052b40998cc203392151d3346d3 Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Mon, 29 Sep 2025 17:51:55 +0300 Subject: [PATCH 08/10] WIP: feat: onUnselectItems in list context should unselect only in same storeKey selection state --- .../field/useReferenceManyFieldController.ts | 9 +++++++- .../list/useInfiniteListController.ts | 11 ++++++++-- .../ra-core/src/controller/list/useList.ts | 9 +++++++- .../src/controller/list/useListController.ts | 11 ++++++++-- .../src/controller/list/useRecordSelection.ts | 2 +- .../src/controller/list/useUnselectAll.ts | 21 ++++++++++++++----- 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index d6efbd07941..07b32823fcd 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -96,6 +96,13 @@ export const useReferenceManyFieldController = < storeKey: props.storeKey ?? `${resource}.${record?.id}`, }); + const onUnselectItems = useCallback( + (fromAllStoreKeys?: boolean) => { + return selectionModifiers.unselect(selectedIds, fromAllStoreKeys); + }, + [selectedIds, selectionModifiers] + ); + // filter logic const filterRef = useRef(filter); const [displayedFilters, setDisplayedFilters] = useState<{ @@ -280,7 +287,7 @@ export const useReferenceManyFieldController = < onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, - onUnselectItems: selectionModifiers.clearSelection, + onUnselectItems, page, perPage, refetch, diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts index 23662d68042..0847eed2f41 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.ts +++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts @@ -1,4 +1,4 @@ -import { isValidElement, useEffect, useMemo } from 'react'; +import { isValidElement, useCallback, useEffect, useMemo } from 'react'; import type { InfiniteQueryObserverBaseResult, InfiniteData, @@ -97,6 +97,13 @@ export const useInfiniteListController = < const [selectedIds, selectionModifiers] = useRecordSelection({ resource }); + const onUnselectItems = useCallback( + (fromAllStoreKeys?: boolean) => { + return selectionModifiers.unselect(selectedIds, fromAllStoreKeys); + }, + [selectedIds, selectionModifiers] + ); + const { data, total, @@ -212,7 +219,7 @@ export const useInfiniteListController = < onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, - onUnselectItems: selectionModifiers.clearSelection, + onUnselectItems, page: query.page, perPage: query.perPage, refetch, diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 072ade60b0a..cadbcbeedbb 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -104,6 +104,13 @@ export const useList = ( : { disableSyncWithStore: true } ); + const onUnselectItems = useCallback( + (fromAllStoreKeys?: boolean) => { + return selectionModifiers.unselect(selectedIds, fromAllStoreKeys); + }, + [selectedIds, selectionModifiers] + ); + // filter logic const filterRef = useRef(filter); const [displayedFilters, setDisplayedFilters] = useState<{ @@ -263,7 +270,7 @@ export const useList = ( onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, - onUnselectItems: selectionModifiers.clearSelection, + onUnselectItems, page, perPage, resource: '', diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 820611a0b05..ab137783feb 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -1,4 +1,4 @@ -import { isValidElement, useEffect, useMemo } from 'react'; +import { isValidElement, useCallback, useEffect, useMemo } from 'react'; import { useAuthenticated, useRequireAccess } from '../../auth'; import { useTranslate } from '../../i18n'; @@ -107,6 +107,13 @@ export const useListController = < storeKey: storeKey === false ? undefined : storeKey, }); + const onUnselectItems = useCallback( + (fromAllStoreKeys?: boolean) => { + return selectionModifiers.unselect(selectedIds, fromAllStoreKeys); + }, + [selectedIds, selectionModifiers] + ); + const { data, pageInfo, @@ -213,7 +220,7 @@ export const useListController = < onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, - onUnselectItems: selectionModifiers.clearSelection, + onUnselectItems, page: query.page, perPage: query.perPage, refetch, diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index ce8e4a4cf3d..ddf2d21be53 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -40,7 +40,7 @@ type SelectionStore = Record< * @param args.storeKey The key to use to store selected items. Pass false to disable synchronization with the store. * @param args.disableSyncWithStore Controls the selection synchronization with the store * - * @returns {Object} Destructure as [selectedIds, { select, toggle, clearSelection }]. + * @returns {Object} Destructure as [selectedIds, { select, unselect, toggle, clearSelection }]. */ export const useRecordSelection = ( args: UseRecordSelectionArgs diff --git a/packages/ra-core/src/controller/list/useUnselectAll.ts b/packages/ra-core/src/controller/list/useUnselectAll.ts index d7dae4d76e8..121a878368b 100644 --- a/packages/ra-core/src/controller/list/useUnselectAll.ts +++ b/packages/ra-core/src/controller/list/useUnselectAll.ts @@ -10,11 +10,22 @@ import { useRecordSelection } from './useRecordSelection'; * const unselectAll = useUnselectAll('posts'); * unselectAll(); */ -export const useUnselectAll = (resource?: string) => { +export const useUnselectAll = ({ + resource, + storeKey, +}: { + resource?: string; + storeKey?: string; +}) => { const [, { clearSelection }] = useRecordSelection( - resource ? { resource } : { disableSyncWithStore: true } + resource + ? { resource, storeKey } + : { disableSyncWithStore: true, storeKey } + ); + return useCallback( + (fromAllStoreKeys?: boolean) => { + clearSelection(fromAllStoreKeys); + }, + [clearSelection] ); - return useCallback(() => { - clearSelection(); - }, [clearSelection]); }; From 4438f9559268c9c9b8b65aaec72db929ff7bdede Mon Sep 17 00:00:00 2001 From: Mikhail Fiadosenka Date: Mon, 29 Sep 2025 17:52:11 +0300 Subject: [PATCH 09/10] WIP: feat: record selection story for List --- .../src/list/List.stories.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 3f7a5d6c845..f77547522db 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -771,6 +771,95 @@ export const LocationNotSyncWithStore = () => { ); }; +const BooksWithStoreKeyA = () => ( + + + + + + + + +); + +const BooksWithStoreKeyB = () => ( + + + + + + + + +); + +const BooksWithoutStoreKey = () => ( + + + + + + + + +); + +const RecordSelectionDashboard = () => ( + <> + + + + + + +); + +export const RecordSelection = () => ( + + + + } + /> + } + /> + } + /> + + + + +); + export const ErrorInFetch = () => ( Date: Tue, 21 Oct 2025 12:32:21 +0300 Subject: [PATCH 10/10] WIP: feat: use separate store for record selection storeKeys --- .../useDeleteWithConfirmController.spec.tsx | 29 ++- .../useReferenceManyFieldController.spec.tsx | 13 +- .../field/useReferenceManyFieldController.ts | 2 +- .../list/useRecordSelection.spec.tsx | 97 ++++++--- .../src/controller/list/useRecordSelection.ts | 187 ++++++++---------- 5 files changed, 179 insertions(+), 149 deletions(-) diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx index 4a98e4303e0..6be0e403bad 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.spec.tsx @@ -125,12 +125,22 @@ describe('useDeleteWithConfirmController', () => { - - } /> - + + + + } + /> + + + @@ -139,11 +149,10 @@ describe('useDeleteWithConfirmController', () => { const button = await screen.findByText('Delete'); fireEvent.click(button); await waitFor( - () => - expect(store.getItem('posts.selectedIds')).toEqual({ - ['']: [123], - ['bar']: [], - }), + () => { + expect(store.getItem('posts.selectedIds')).toEqual([123]); + expect(store.getItem('bar.selectedIds')).toEqual([]); + }, { timeout: 1000, } diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index f9f05e6f538..2efc58fa984 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -362,9 +362,10 @@ describe('useReferenceManyFieldController', () => { fireEvent.click(await screen.findByText('Toggle')); await waitFor(() => { - expect(setStore).toHaveBeenCalledWith('books.selectedIds', { - ['authors.123']: [456], - }); + expect(setStore).toHaveBeenCalledWith( + 'authors.123.books.selectedIds', + [456] + ); }); }); @@ -399,9 +400,9 @@ describe('useReferenceManyFieldController', () => { fireEvent.click(await screen.findByText('Toggle')); await waitFor(() => { - expect(setStore).toHaveBeenCalledWith('books.selectedIds', { - ['customKey']: [456], - }); + expect(setStore).toHaveBeenCalledWith('customKey.selectedIds', [ + 456, + ]); }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index 07b32823fcd..bb2550dc5f1 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -93,7 +93,7 @@ export const useReferenceManyFieldController = < // selection logic const [selectedIds, selectionModifiers] = useRecordSelection({ resource: reference, - storeKey: props.storeKey ?? `${resource}.${record?.id}`, + storeKey: props.storeKey ?? `${resource}.${record?.id}.${reference}`, }); const onUnselectItems = useCallback( diff --git a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx index ba7ea1bb3da..264ea4e6f09 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx +++ b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx @@ -23,26 +23,6 @@ describe('useRecordSelection', () => { }); it('should use the stored value', () => { - const { result } = renderHook( - () => useRecordSelection({ resource: 'foo' }), - { - wrapper: ({ children }) => ( - - - {children} - - - ), - } - ); - const [selected] = result.current; - expect(selected).toEqual([123, 456]); - }); - - it('should use the stored value in previous format', () => { const { result } = renderHook( () => useRecordSelection({ resource: 'foo' }), { @@ -78,9 +58,7 @@ describe('useRecordSelection', () => { select([123, 456, 7]); await waitFor(() => { const stored = store.getItem('foo.selectedIds'); - expect(stored).toEqual({ - ['']: [123, 456, 7], - }); + expect(stored).toEqual([123, 456, 7]); }); }); @@ -444,8 +422,8 @@ describe('useRecordSelection', () => { wrapper: ({ children }) => ( {children} @@ -533,5 +511,74 @@ describe('useRecordSelection', () => { expect(selected2).toEqual([]); }); }); + + describe('using stored storeKeys', () => { + it('should keep final storeKey in the store', async () => { + const store = memoryStore(); + renderHook( + () => + useRecordSelection({ + resource: 'foo', + storeKey: 'bar', + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + await waitFor(() => { + const storeKeys = store.getItem( + 'foo.selectedIds.storeKeys' + ); + expect(storeKeys).toEqual(['bar.selectedIds']); + }); + }); + + it('should check all storeKeys listed in store when `fromAllStoreKeys` is `true`', async () => { + const store = memoryStore(); + const { result } = renderHook( + () => { + return useRecordSelection({ + resource: 'foo', + storeKey: 'bar1', + }); + }, + { + wrapper: ({ children }) => ( + + + + + {children} + + + + + ), + } + ); + + const [, { clearSelection }] = result.current; + clearSelection(true); + + await waitFor(() => { + expect(store.getItem('bar1.selectedIds')).toEqual([]); + expect(store.getItem('bar2.selectedIds')).toEqual([]); + }); + }); + }); }); }); diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index ddf2d21be53..51430110df9 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { useStore } from '../../store'; +import { useStore, useStoreContext } from '../../store'; import { RaRecord } from '../../types'; type UseRecordSelectionWithResourceArgs = { @@ -28,11 +28,6 @@ export type UseRecordSelectionResult = [ }, ]; -type SelectionStore = Record< - string, - RecordType['id'][] ->; - /** * Get the list of selected items for a resource, and callbacks to change the selection * @@ -47,39 +42,43 @@ export const useRecordSelection = ( ): UseRecordSelectionResult => { const { resource = '', storeKey, disableSyncWithStore } = args; - const namespace = storeKey ?? defaultNamespace; - - const finalStoreKey = `${resource}.selectedIds`; + const finalStoreKey = `${storeKey || resource}.selectedIds`; - const [localSelectionStore, setLocalSelectionStore] = useState< - SelectionStore - >(defaultSelectionStore); + const [localSelectionStore, setLocalSelectionStore] = + useState(defaultIds); // As we can't conditionally call a hook, if the disableSyncWithStore is true, - // we'll ignore the store value later on and won't call setSelectionStore either. - const [selectionStoreUnknownVersion, setSelectionStore] = useStore< - SelectionStore - >(finalStoreKey, defaultSelectionStore); + // we'll ignore the useStore values later on and won't call set functions either. + const [selectionStore, setSelectionStore] = useStore( + finalStoreKey, + defaultIds + ); + const [storeKeys, setStoreKeys] = useStore( + `${resource}.selectedIds.storeKeys`, + defaultStoreKeys + ); + + useEffect( + function addStoreKeyToStore() { + if (!disableSyncWithStore && storeKey) { + setStoreKeys(storeKeys => { + if (!storeKeys.includes(finalStoreKey)) { + return [...storeKeys, finalStoreKey]; + } else { + return storeKeys; + } + }); + } + }, + [disableSyncWithStore, finalStoreKey, setStoreKeys, storeKey] + ); - const store = disableSyncWithStore - ? localSelectionStore - : migrateSelectionStoreToNewVersion(selectionStoreUnknownVersion); - const ids = store[namespace] ?? defaultEmptyIds; + const { getItem, setItem } = useStoreContext(); + + const ids = disableSyncWithStore ? localSelectionStore : selectionStore; const setStore = useMemo( () => - disableSyncWithStore - ? setLocalSelectionStore - : (function migrateAndSetSelectionStore(valueOrSetter) { - if (typeof valueOrSetter === 'function') { - setSelectionStore(prevValue => - valueOrSetter( - migrateSelectionStoreToNewVersion(prevValue) - ) - ); - } else { - setSelectionStore(valueOrSetter); - } - } satisfies typeof setSelectionStore), + disableSyncWithStore ? setLocalSelectionStore : setSelectionStore, [disableSyncWithStore, setSelectionStore] ); @@ -88,100 +87,74 @@ export const useRecordSelection = ( select: (idsToSelect: RecordType['id'][]) => { if (!idsToSelect) return; - setStore(store => ({ - ...store, - [namespace]: [...idsToSelect], - })); + setStore(idsToSelect); }, unselect( idsToRemove: RecordType['id'][], fromAllStoreKeys?: boolean ) { if (!idsToRemove || idsToRemove.length === 0) return; - setStore(store => { - if (!fromAllStoreKeys) { - return { - ...store, - [namespace]: store[namespace]?.filter( - id => !idsToRemove.includes(id) - ), - }; - } else { - return Object.fromEntries( - Object.entries(store).map(([namespace, ids]) => { - return [ - namespace, - ids?.filter( - id => !idsToRemove.includes(id) - ), - ]; - }) - ); - } - }); + + setStore(ids => ids.filter(id => !idsToRemove.includes(id))); + + if (!disableSyncWithStore && fromAllStoreKeys) { + storeKeys + .filter(storeKey => storeKey !== finalStoreKey) + .forEach(storeKey => { + const ids = getItem(storeKey); + if (ids) { + setItem( + storeKey, + ids.filter(id => !idsToRemove.includes(id)) + ); + } + }); + } }, toggle: (id: RecordType['id']) => { if (typeof id === 'undefined') return; - setStore(store => { - const ids = store[namespace] ?? defaultEmptyIds; - - if (!Array.isArray(ids)) - return { ...store, [namespace]: [...ids] }; + setStore(ids => { + if (!Array.isArray(ids)) return [...ids]; const index = ids.indexOf(id); const hasId = index > -1; - return { - ...store, - [namespace]: hasId - ? [...ids.slice(0, index), ...ids.slice(index + 1)] - : [...ids, id], - }; + return hasId + ? [...ids.slice(0, index), ...ids.slice(index + 1)] + : [...ids, id]; }); }, clearSelection: (fromAllStoreKeys?: boolean) => { - setStore(store => { - if (fromAllStoreKeys) { - console.log( - store, - Object.fromEntries( - Object.keys(store).map(namespace => [ - namespace, - [], - ]) - ) - ); - - return Object.fromEntries( - Object.keys(store).map(namespace => [namespace, []]) - ); - } else { - return { - ...store, - [namespace]: [], - }; - } - }); + setStore(defaultIds); + + if (!disableSyncWithStore && fromAllStoreKeys) { + storeKeys + .filter(storeKey => storeKey !== finalStoreKey) + .forEach(storeKey => { + const ids = getItem(storeKey); + if (ids) { + setItem( + storeKey, + defaultIds + ); + } + }); + } }, }), - [setStore, namespace] + [ + disableSyncWithStore, + finalStoreKey, + getItem, + setItem, + setStore, + storeKeys, + ] ); return [ids, selectionModifiers]; }; -const defaultNamespace = ''; -const defaultSelectionStore = {}; -const defaultEmptyIds = []; - -function migrateSelectionStoreToNewVersion( - selectionStoreUnknownVersion: SelectionStore -) { - return Array.isArray(selectionStoreUnknownVersion) - ? { - ...defaultSelectionStore, - [defaultNamespace]: selectionStoreUnknownVersion, - } - : selectionStoreUnknownVersion; -} +const defaultIds = []; +const defaultStoreKeys = [];