Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore(ArrayField): add support for storeKey to manage independent selection states #10390

Open
wants to merge 4 commits into
base: next
Choose a base branch
from
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
42 changes: 36 additions & 6 deletions docs/ArrayField.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|

`<ArrayField>` accepts the [common field props](./Fields.md#common-field-props), except `emptyText` (use the child `empty` prop instead).

Expand Down Expand Up @@ -217,6 +218,35 @@ By default, `<ArrayField>` 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 (`customOne.selectedIds` and `customTwo.selectedIds`). This ensures that both components can coexist on the same page without interfering with each other's state.

```jsx
<Stack direction="row" spacing={2}>
<ArrayField
source="books"
storeKey="customOne"
>
<Datagrid>
<TextField source="title" />
</Datagrid>
</ArrayField>
<ArrayField
source="books"
storeKey="customTwo"
>
<Datagrid>
<TextField source="title" />
</Datagrid>
</ArrayField>
</Stack>
```

## Using The List Context

`<ArrayField>` 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.
Expand Down
57 changes: 55 additions & 2 deletions packages/ra-ui-materialui/src/field/ArrayField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import {
CoreAdminContext,
ResourceContextProvider,
Expand All @@ -12,7 +12,23 @@ import { NumberField } from './NumberField';
import { TextField } from './TextField';
import { Datagrid } from '../list';
import { SimpleList } from '../list';
import { ListContext } from './ArrayField.stories';
import { ListContext, TwoArrayFieldsSelection } from './ArrayField.stories';

beforeAll(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, this is a footgun as it will hide potentially important errors. I'd prefer that you do the mock in individual tests and only when necessary

jest.spyOn(console, 'error').mockImplementation((message, ...args) => {
if (
typeof message === 'string' &&
message.includes('React will try recreating this component tree')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a normal error message - it probably comes from your story, your test, or your implementation.

) {
return;
}
console.warn(message, ...args); // Still log other errors
});
});

afterAll(() => {
jest.restoreAllMocks();
});

describe('<ArrayField />', () => {
const sort = { field: 'id', order: 'ASC' };
Expand Down Expand Up @@ -158,4 +174,41 @@ describe('<ArrayField />', () => {
).toBeTruthy();
});
});

it('should not select the same id in both ArrayFields when selected in one', async () => {
render(<TwoArrayFieldsSelection />);
await waitFor(() => {
expect(screen.queryAllByRole('checkbox').length).toBeGreaterThan(2);
});

const checkboxes = screen.queryAllByRole('checkbox');

expect(checkboxes.length).toBeGreaterThan(3);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By reading the test, I don't know why you wait for that. If you want to wait for the two lists to render, please use a findByText looking for the actual array values instead.


// Select an item in the memberships list
fireEvent.click(checkboxes[1]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you enabled rowClick, I suggest you trigger a click on a piece of text instead. This would make the test much more readable as it's not obvious what checkboxes[1] is.

render(<TwoArrayFieldsSelection />);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't need to rerender. If you want to come back to the initial state, use more than one it() and group them in a describe().


await waitFor(() => {
expect(checkboxes[1]).toBeChecked();
});

expect(checkboxes[3]).not.toBeChecked();

fireEvent.click(checkboxes[3]); // Portfolios row 1
render(<TwoArrayFieldsSelection />);

await waitFor(() => {
expect(checkboxes[3]).toBeChecked();
expect(checkboxes[1]).toBeChecked(); // Membership remains checked
});

fireEvent.click(checkboxes[1]);
render(<TwoArrayFieldsSelection />);

await waitFor(() => {
expect(checkboxes[1]).not.toBeChecked();
expect(checkboxes[3]).toBeChecked(); // Portfolios remain selected
});
});
});
67 changes: 67 additions & 0 deletions packages/ra-ui-materialui/src/field/ArrayField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,70 @@ export const InShowLayout = () => (
</AdminContext>
</TestMemoryRouter>
);

export const TwoArrayFieldsSelection = () => (
<TestMemoryRouter>
<AdminContext>
<ResourceContextProvider value="organizations">
<RecordContextProvider
value={{
id: 1,
name: 'Acme Corp',
memberships: [
{ id: 1, userId: 1001, role: 'Admin' },
{ id: 2, userId: 1002, role: 'Member' },
],
portfolios: [
{
id: 1,
name: 'Growth Portfolio',
creatorId: 1001,
},
{
id: 2,
name: 'Tech Innovations',
creatorId: 1002,
},
],
}}
>
<Card sx={{ m: 1, p: 1 }}>
<SimpleShowLayout>
<TextField source="name" />

{/* Memberships ArrayField */}
<ArrayField
source="memberships"
storeKey="organization_memberships"
>
<Datagrid
rowClick="toggleSelection"
bulkActionButtons={true}
isRowSelectable={() => true}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rows are selectable by default, you shouldn't need this line. Same for the second ArrayInput.

>
<TextField source="id" />
<TextField source="role" />
</Datagrid>
</ArrayField>

{/* Portfolios ArrayField */}
<ArrayField
source="portfolios"
storeKey="organization_portfolios"
>
<Datagrid
rowClick="toggleSelection"
bulkActionButtons={true}
isRowSelectable={() => true}
>
<TextField source="id" />
<TextField source="name" />
</Datagrid>
</ArrayField>
</SimpleShowLayout>
</Card>
</RecordContextProvider>
</ResourceContextProvider>
</AdminContext>
</TestMemoryRouter>
);
11 changes: 9 additions & 2 deletions packages/ra-ui-materialui/src/field/ArrayField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ const ArrayFieldImpl = <
>(
props: ArrayFieldProps<RecordType>
) => {
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: storeKey || resource, // Prioritize storeKey if provided
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer that you add support for a custom storeKey in useList. I know that we currently use resource only for selection in useList, but this may change in the future.

perPage,
sort,
filter,
});
return (
<ListContextProvider value={listContext}>
{children}
Expand All @@ -99,6 +105,7 @@ export interface ArrayFieldProps<
perPage?: number;
sort?: SortPayload;
filter?: FilterPayload;
storeKey?: string | false;
}

const emptyArray = [];