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 (`books.selectedIds` and `custom.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
130 changes: 130 additions & 0 deletions packages/ra-core/src/controller/list/useList.storeKey.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestMemoryRouter initialEntries={['/top']}>
<ListsWithStoreKeys />
</TestMemoryRouter>
);

// 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(
<TestMemoryRouter initialEntries={['/store']}>
<ListsWithoutStoreKeys />
</TestMemoryRouter>
);

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');
});
});
});
154 changes: 154 additions & 0 deletions packages/ra-core/src/controller/list/useList.storeKey.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span aria-label="storeKey" data-value={storeKey}>
storeKey: {storeKey}
</span>
<br />
<span aria-label="perPage" data-value={params.perPage}>
perPage: {params.perPage}
</span>
<br />
<button
aria-label="incrementPerPage"
onClick={() => {
return params.setPerPage(++params.perPage);
}}
>
Increment perPage
</button>
<ul style={styles.ul}>
{params.data?.map(post => (
<li key={post.id}>
{post.title} - {post.votes} votes
</li>
))}
</ul>
</div>
);
};

const Layout = (props: CoreLayoutProps) => (
<div style={styles.mainContainer}>
<Link aria-label="top" to={`/top`}>
Go to Top Posts
</Link>
<Link aria-label="flop" to={`/flop`}>
Go to Flop Posts
</Link>
<Link aria-label="store" to={`/store`}>
Go to Store List
</Link>
<Link aria-label="nostore" to={`/nostore`}>
Go to No-Store List
</Link>

<br />
{props.children}
</div>
);

const TopPosts = (
<OrderedPostList
storeKey="topPostsKey"
sort={{ field: 'votes', order: 'DESC' }}
/>
);
const FlopPosts = (
<OrderedPostList
storeKey="flopPostsKey"
sort={{ field: 'votes', order: 'ASC' }}
/>
);
const StorePosts = (
<OrderedPostList
storeKey="storeListKey"
sort={{ field: 'votes', order: 'ASC' }}
/>
);
const NoStorePosts = (
<OrderedPostList storeKey={false} sort={{ field: 'votes', order: 'ASC' }} />
);

export const ListsWithStoreKeys = () => (
<CoreAdminContext store={localStorageStore()} dataProvider={dataProvider}>
<CoreAdminUI layout={Layout}>
<CustomRoutes>
<Route path="/top" element={TopPosts} />
<Route path="/flop" element={FlopPosts} />
</CustomRoutes>
<Resource name="posts" />
</CoreAdminUI>
</CoreAdminContext>
);

export const ListsWithoutStoreKeys = () => (
<CoreAdminContext store={localStorageStore()} dataProvider={dataProvider}>
<CoreAdminUI layout={Layout}>
<CustomRoutes>
<Route path="/store" element={StorePosts} />
<Route path="/nostore" element={NoStorePosts} />
</CustomRoutes>
<Resource name="posts" />
</CoreAdminUI>
</CoreAdminContext>
);
Loading