Skip to content

Commit b905e3e

Browse files
authored
Merge pull request #8073 from marmelab/rao8038-list-store-key
Add ability to create independent store configurations for different lists of same resource
2 parents 847498c + a4f4181 commit b905e3e

9 files changed

+405
-23
lines changed

docs/List.md

+66
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,72 @@ export const PostList = () => (
676676

677677
For more details on list sort, see the [Sorting The List](./ListTutorial.md#sorting-the-list) section below.
678678

679+
## `storeKey`
680+
681+
To display multiple lists of the same resource and keep distinct store states for each of them (filters, sorting and pagination), specify unique keys with the `storeKey` property.
682+
683+
In case no `storeKey` is provided, the states will be stored with the following key: `${resource}.listParams`.
684+
685+
**Note:** Please note that selection state will remain linked to a resource-based key as described [here](./List.md#disablesyncwithlocation).
686+
687+
In the example below, both lists `NewerBooks` and `OlderBooks` use the same resource ('books'), but their controller states are stored separately (under the store keys `'newerBooks'` and `'olderBooks'` respectively). This allows to use both components in the same app, each having its own state (filters, sorting and pagination).
688+
689+
```jsx
690+
import {
691+
Admin,
692+
CustomRoutes,
693+
Resource,
694+
List,
695+
Datagrid,
696+
TextField,
697+
} from 'react-admin';
698+
import { Route } from 'react-router';
699+
700+
const NewerBooks = () => (
701+
<List
702+
resource="books"
703+
storeKey="newerBooks"
704+
sort={{ field: 'year', order: 'DESC' }}
705+
>
706+
<Datagrid>
707+
<TextField source="id" />
708+
<TextField source="title" />
709+
<TextField source="author" />
710+
<TextField source="year" />
711+
</Datagrid>
712+
</List>
713+
);
714+
715+
const OlderBooks = () => (
716+
<List
717+
resource="books"
718+
storeKey="olderBooks"
719+
sort={{ field: 'year', order: 'ASC' }}
720+
>
721+
<Datagrid>
722+
<TextField source="id" />
723+
<TextField source="title" />
724+
<TextField source="author" />
725+
<TextField source="year" />
726+
</Datagrid>
727+
</List>
728+
);
729+
730+
const Admin = () => {
731+
return (
732+
<Admin dataProvider={dataProvider}>
733+
<CustomRoutes>
734+
<Route path="/newerBooks" element={<NewerBooks />} />
735+
<Route path="/olderBooks" element={<OlderBooks />} />
736+
</CustomRoutes>
737+
<Resource name="books" />
738+
</Admin>
739+
);
740+
};
741+
```
742+
743+
**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.
744+
679745
## `title`
680746

681747
The default title for a list view is "[resource] list" (e.g. "Posts list"). Use the `title` prop to customize the List view title:

docs/useListController.md

+50-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ title: "useListController"
55

66
# `useListController`
77

8-
The `useListController` hook fetches the data, prepares callbacks for modifying the pagination, filters, sort and selection, and returns them. Its return value match the `ListContext` shape. `useListController` is used internally by the `<List>` and `<ListBase>` components.
8+
The `useListController` hook fetches the data, prepares callbacks for modifying the pagination, filters, sort and selection, and returns them. Its return value match the `ListContext` shape. `useListController` is used internally by the `<List>` and `<ListBase>` components.
99

10-
You can use it to create a custom List view, although its component counterpart, [`<ListBase>`](./ListBase.md), is probably better in most cases.
10+
You can use it to create a custom List view, although its component counterpart, [`<ListBase>`](./ListBase.md), is probably better in most cases.
1111

1212
## Usage
1313

@@ -43,11 +43,12 @@ const MyList = () => {
4343
* [`queryOptions`](./List.md#queryoptions): react-query options for the useQuery call
4444
* [`resource`](./List.md#resource): resource name, e.g. 'posts' ; defaults to the current resource context
4545
* [`sort`](./List.md#sort-default-sort-field--order), current sort value, e.g. { field: 'published_at', order: 'DESC' }
46+
* [`storeKey`](#storekey): key used to differenciate the list from another sharing the same resource, in store managed states
4647

4748
Here are their default values:
4849

4950
```jsx
50-
import {
51+
import {
5152
useListController,
5253
defaultExporter,
5354
ListContextProvider
@@ -64,6 +65,7 @@ const MyList = ({
6465
queryOptions = undefined,
6566
resource = '',
6667
sort = { field: 'id', order: 'DESC' },
68+
storeKey = undefined,
6769
}) => {
6870
const listContext = useListController({
6971
debounce,
@@ -76,6 +78,7 @@ const MyList = ({
7678
queryOptions,
7779
resource,
7880
sort,
81+
storeKey,
7982
});
8083
return (
8184
<ListContextProvider value={listContext}>
@@ -85,6 +88,50 @@ const MyList = ({
8588
};
8689
```
8790

91+
## `storeKey`
92+
93+
To display multiple lists of the same resource and keep distinct store states for each of them (filters, sorting and pagination), specify unique keys with the `storeKey` property.
94+
95+
In case no `storeKey` is provided, the states will be stored with the following key: `${resource}.listParams`.
96+
97+
**Note:** Please note that selection state will remain linked to a resource-based key as described [here](./List.md#disablesyncwithlocation).
98+
99+
In the example below, both lists `TopPosts` and `FlopPosts` use the same resource ('posts'), but their controller states are stored separately (under the store keys `'top'` and `'flop'` respectively).
100+
101+
```jsx
102+
import { useListController } from 'react-admin';
103+
104+
const OrderedPostList = ({
105+
storeKey,
106+
sort,
107+
}) => {
108+
const params = useListController({
109+
resource: 'posts',
110+
sort,
111+
storeKey,
112+
});
113+
return (
114+
<div>
115+
<ul style={styles.ul}>
116+
{!params.isLoading &&
117+
params.data.map(post => (
118+
<li key={`post_${post.id}`}>
119+
{post.title} - {post.votes} votes
120+
</li>
121+
))}
122+
</ul>
123+
</div>
124+
);
125+
};
126+
127+
const TopPosts = (
128+
<OrderedPostList storeKey="top" sort={{ field: 'votes', order: 'DESC' }} />
129+
);
130+
const FlopPosts = (
131+
<OrderedPostList storeKey="flop" sort={{ field: 'votes', order: 'ASC' }} />
132+
);
133+
```
134+
88135
## Return Value
89136

90137
The return value of `useListController` has the following shape:

packages/ra-core/src/controller/list/useListController.spec.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ describe('useListController', () => {
235235
clock.uninstall();
236236
});
237237
});
238+
238239
describe('showFilter', () => {
239240
it('Does not remove previously shown filter when adding a new one', async () => {
240241
let currentDisplayedFilters;
@@ -409,6 +410,7 @@ describe('useListController', () => {
409410
});
410411
});
411412
});
413+
412414
describe('getListControllerProps', () => {
413415
it('should only pick the props injected by the ListController', () => {
414416
expect(
@@ -449,6 +451,7 @@ describe('useListController', () => {
449451
});
450452
});
451453
});
454+
452455
describe('sanitizeListRestProps', () => {
453456
it('should omit the props injected by the ListController', () => {
454457
expect(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as React from 'react';
2+
import {
3+
render,
4+
fireEvent,
5+
screen,
6+
waitFor,
7+
act,
8+
} from '@testing-library/react';
9+
import { createMemoryHistory } from 'history';
10+
import { ListsUsingSameResource } from './useListController.storeKey.stories';
11+
12+
describe('useListController', () => {
13+
describe('customStoreKey', () => {
14+
it('should keep distinct two lists of the same resource given different keys', async () => {
15+
render(
16+
<ListsUsingSameResource
17+
history={createMemoryHistory({
18+
initialEntries: ['/top'],
19+
})}
20+
/>
21+
);
22+
23+
await waitFor(() => {
24+
expect(
25+
screen.getByLabelText('perPage').getAttribute('data-value')
26+
).toEqual('3');
27+
});
28+
29+
act(() => {
30+
fireEvent.click(screen.getByLabelText('incrementPerPage'));
31+
});
32+
33+
await waitFor(() => {
34+
expect(
35+
screen.getByLabelText('perPage').getAttribute('data-value')
36+
).toEqual('4');
37+
});
38+
39+
act(() => {
40+
fireEvent.click(screen.getByLabelText('flop'));
41+
});
42+
expect(
43+
screen.getByLabelText('perPage').getAttribute('data-value')
44+
).toEqual('3');
45+
});
46+
});
47+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import * as React from 'react';
2+
import { Route } from 'react-router';
3+
import { Link } from 'react-router-dom';
4+
import fakeDataProvider from 'ra-data-fakerest';
5+
6+
import {
7+
CoreAdminContext,
8+
CoreAdminUI,
9+
CustomRoutes,
10+
Resource,
11+
} from '../../core';
12+
import { localStorageStore } from '../../store';
13+
import { FakeBrowserDecorator } from '../../storybook/FakeBrowser';
14+
import { CoreLayoutProps, SortPayload } from '../../types';
15+
import { useListController } from './useListController';
16+
17+
export default {
18+
title: 'ra-core/controller/list/useListController',
19+
decorators: [FakeBrowserDecorator],
20+
parameters: {
21+
initialEntries: ['/top'],
22+
},
23+
};
24+
25+
const styles = {
26+
mainContainer: {
27+
margin: '20px 10px',
28+
},
29+
30+
ul: {
31+
marginTop: '20px',
32+
padding: '10px',
33+
},
34+
};
35+
36+
const dataProvider = fakeDataProvider({
37+
posts: [
38+
{ id: 1, title: 'Post #1', votes: 90 },
39+
{ id: 2, title: 'Post #2', votes: 20 },
40+
{ id: 3, title: 'Post #3', votes: 30 },
41+
{ id: 4, title: 'Post #4', votes: 40 },
42+
{ id: 5, title: 'Post #5', votes: 50 },
43+
{ id: 6, title: 'Post #6', votes: 60 },
44+
{ id: 7, title: 'Post #7', votes: 70 },
45+
],
46+
});
47+
48+
const OrderedPostList = ({
49+
storeKey,
50+
sort,
51+
}: {
52+
storeKey: string;
53+
sort?: SortPayload;
54+
}) => {
55+
const params = useListController({
56+
resource: 'posts',
57+
debounce: 200,
58+
perPage: 3,
59+
sort,
60+
storeKey,
61+
});
62+
return (
63+
<div>
64+
<span aria-label="storeKey" data-value={storeKey}>
65+
storeKey: {storeKey}
66+
</span>
67+
<br />
68+
<span aria-label="perPage" data-value={params.perPage}>
69+
perPage: {params.perPage}
70+
</span>
71+
<br />
72+
<br />
73+
<button
74+
aria-label="incrementPerPage"
75+
disabled={params.perPage > params.data?.length ?? false}
76+
onClick={() => params.setPerPage(++params.perPage)}
77+
>
78+
Increment perPage
79+
</button>{' '}
80+
<button
81+
aria-label="decrementPerPage"
82+
disabled={params.perPage <= 0}
83+
onClick={() => params.setPerPage(--params.perPage)}
84+
>
85+
Decrement perPage
86+
</button>
87+
<ul style={styles.ul}>
88+
{!params.isLoading &&
89+
params.data.map(post => (
90+
<li key={`post_${post.id}`}>
91+
{post.title} - {post.votes} votes
92+
</li>
93+
))}
94+
</ul>
95+
</div>
96+
);
97+
};
98+
99+
const MinimalLayout = (props: CoreLayoutProps) => {
100+
return (
101+
<div style={styles.mainContainer}>
102+
<Link aria-label="top" to={`/top`}>
103+
Go to Top List
104+
</Link>{' '}
105+
<Link aria-label="flop" to={`/flop`}>
106+
Go to Flop List
107+
</Link>
108+
<br />
109+
<br />
110+
{props.children}
111+
</div>
112+
);
113+
};
114+
const TopPosts = (
115+
<OrderedPostList storeKey="top" sort={{ field: 'votes', order: 'DESC' }} />
116+
);
117+
const FlopPosts = (
118+
<OrderedPostList storeKey="flop" sort={{ field: 'votes', order: 'ASC' }} />
119+
);
120+
121+
export const ListsUsingSameResource = (argsOrProps, context) => {
122+
const history = context?.history || argsOrProps.history;
123+
return (
124+
<CoreAdminContext
125+
history={history}
126+
store={localStorageStore()}
127+
dataProvider={dataProvider}
128+
>
129+
<CoreAdminUI layout={MinimalLayout}>
130+
<CustomRoutes>
131+
<Route path="/top" element={TopPosts} />
132+
</CustomRoutes>
133+
<CustomRoutes>
134+
<Route path="/flop" element={FlopPosts} />
135+
</CustomRoutes>
136+
<Resource name="posts" />
137+
</CoreAdminUI>
138+
</CoreAdminContext>
139+
);
140+
};

0 commit comments

Comments
 (0)