Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,7 +92,8 @@ export const useReferenceManyFieldController = <

// selection logic
const [selectedIds, selectionModifiers] = useRecordSelection({
resource: storeKey,
resource: reference,
storeKey: props.storeKey ?? `${resource}.${record?.id}`,
});

// filter logic
Expand Down
3 changes: 2 additions & 1 deletion packages/ra-core/src/controller/list/useListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const useListController = <
const [selectedIds, selectionModifiers] = useRecordSelection({
resource,
disableSyncWithStore: storeKey === false,
storeKey: storeKey === false ? undefined : storeKey,
});

const {
Expand Down Expand Up @@ -515,7 +516,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
156 changes: 156 additions & 0 deletions packages/ra-core/src/controller/list/useRecordSelection.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ describe('useRecordSelection', () => {
});

it('should use the stored value', () => {
const { result } = renderHook(
() => useRecordSelection({ resource: 'foo' }),
{
wrapper: ({ children }) => (
<StoreContextProvider value={memoryStore()}>
<StoreSetter
name="foo.selectedIds"
value={{ ['']: [123, 456] }}
>
{children}
</StoreSetter>
</StoreContextProvider>
),
}
);
const [selected] = result.current;
expect(selected).toEqual([123, 456]);
});

it('should use the stored value in previous format', () => {
const { result } = renderHook(
() => useRecordSelection({ resource: 'foo' }),
{
Expand All @@ -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 }) => (
<StoreContextProvider value={store}>
<StoreSetter name="foo.selectedIds" value={[123, 456]}>
{children}
</StoreSetter>
</StoreContextProvider>
),
}
);

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(
Expand Down Expand Up @@ -378,4 +423,115 @@ 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 }) => (
<StoreContextProvider value={memoryStore()}>
<StoreSetter
name="foo.selectedIds"
value={{ bar: [123, 456] }}
>
{children}
</StoreSetter>
</StoreContextProvider>
),
}
);
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([]);
});
});
});
});
Loading