Skip to content
Open
6 changes: 4 additions & 2 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const useBulkDeleteController = <
undoable: mutationMode === 'undoable',
}
);
onUnselectItems();
onUnselectItems(true);
},
onError: (error: any) => {
notify(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 <button onClick={handleDelete}>Delete</button>;
};

const store = memoryStore();

render(
<TestMemoryRouter>
<CoreAdminContext store={store} dataProvider={dataProvider}>
<StoreSetter
name="posts.selectedIds"
value={{ ['']: [123, 456], ['bar']: [456] }}
>
<Routes>
<Route path="/" element={<MockComponent />} />
</Routes>
</StoreSetter>
</CoreAdminContext>
</TestMemoryRouter>
);

const button = await screen.findByText('Delete');
fireEvent.click(button);
await waitFor(
() =>
expect(store.getItem('posts.selectedIds')).toEqual({
['']: [123],
['bar']: [],
}),
{
timeout: 1000,
}
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const useDeleteWithConfirmController = <
undoable: mutationMode === 'undoable',
}
);
record && unselect([record.id]);
record && unselect([record.id], true);
redirect(redirectTo, resource);
},
onError: error => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,42 @@ describe('useReferenceManyFieldController', () => {
});
});

it('should store selection state linked to referencing record', async () => {
const store = memoryStore();
const setStore = jest.spyOn(store, 'setItem');

render(
<CoreAdminContext store={store}>
<ReferenceManyFieldController
resource="authors"
source="uniqueName"
record={{
id: 123,
uniqueName: 'jamesjoyce256',
name: 'James Joyce',
}}
reference="books"
target="author_id"
>
{({ onToggleItem }) => {
return (
<button onClick={() => onToggleItem(456)}>
Toggle
</button>
);
}}
</ReferenceManyFieldController>
</CoreAdminContext>
);

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');
Expand All @@ -352,7 +388,7 @@ describe('useReferenceManyFieldController', () => {
>
{({ onToggleItem }) => {
return (
<button onClick={() => onToggleItem(123)}>
<button onClick={() => onToggleItem(456)}>
Toggle
</button>
);
Expand All @@ -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],
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -93,9 +92,17 @@ export const useReferenceManyFieldController = <

// selection logic
const [selectedIds, selectionModifiers] = useRecordSelection({
resource: storeKey,
resource: reference,
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<{
Expand Down Expand Up @@ -280,7 +287,7 @@ export const useReferenceManyFieldController = <
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
onUnselectItems: selectionModifiers.clearSelection,
onUnselectItems,
page,
perPage,
refetch,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isValidElement, useEffect, useMemo } from 'react';
import { isValidElement, useCallback, useEffect, useMemo } from 'react';
import type {
InfiniteQueryObserverBaseResult,
InfiniteData,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/ra-core/src/controller/list/useList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export const useList = <RecordType extends RaRecord = any, ErrorType = Error>(
: { disableSyncWithStore: true }
);

const onUnselectItems = useCallback(
(fromAllStoreKeys?: boolean) => {
return selectionModifiers.unselect(selectedIds, fromAllStoreKeys);
},
[selectedIds, selectionModifiers]
);

// filter logic
const filterRef = useRef(filter);
const [displayedFilters, setDisplayedFilters] = useState<{
Expand Down Expand Up @@ -263,7 +270,7 @@ export const useList = <RecordType extends RaRecord = any, ErrorType = Error>(
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
onUnselectItems: selectionModifiers.clearSelection,
onUnselectItems,
page,
perPage,
resource: '',
Expand Down
14 changes: 11 additions & 3 deletions packages/ra-core/src/controller/list/useListController.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -104,8 +104,16 @@ export const useListController = <
const [selectedIds, selectionModifiers] = useRecordSelection({
resource,
disableSyncWithStore: storeKey === false,
storeKey: storeKey === false ? undefined : storeKey,
});

const onUnselectItems = useCallback(
(fromAllStoreKeys?: boolean) => {
return selectionModifiers.unselect(selectedIds, fromAllStoreKeys);
},
[selectedIds, selectionModifiers]
);

const {
data,
pageInfo,
Expand Down Expand Up @@ -212,7 +220,7 @@ export const useListController = <
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
onUnselectItems: selectionModifiers.clearSelection,
onUnselectItems,
page: query.page,
perPage: query.perPage,
refetch,
Expand Down Expand Up @@ -515,7 +523,7 @@ export interface ListControllerBaseResult<RecordType extends RaRecord = any> {
| UseReferenceManyFieldControllerParams<RecordType>['queryOptions'];
}) => void;
onToggleItem: (id: RecordType['id']) => void;
onUnselectItems: () => void;
onUnselectItems: (fromAllStoreKeys?: boolean) => void;
page: number;
perPage: number;
refetch: (() => void) | UseGetListHookValue<RecordType>['refetch'];
Expand Down
Loading