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

Add ability to create independent store configurations for different lists of same resource #8073

Merged
merged 10 commits into from
Sep 19, 2022
33 changes: 30 additions & 3 deletions docs/useListController.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ title: "useListController"

# `useListController`

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.
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.

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

## Usage

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

Here are their default values:

```jsx
import {
import {
useListController,
defaultExporter,
ListContextProvider
Expand All @@ -64,6 +65,7 @@ const MyList = ({
queryOptions = undefined,
resource = '',
sort = { field: 'id', order: 'DESC' },
storeKey = undefined,
}) => {
const listContext = useListController({
debounce,
Expand All @@ -76,6 +78,7 @@ const MyList = ({
queryOptions,
resource,
sort,
storeKey,
});
return (
<ListContextProvider value={listContext}>
Expand All @@ -85,6 +88,30 @@ const MyList = ({
};
```

## `storeKey`

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.
slax57 marked this conversation as resolved.
Show resolved Hide resolved

In case no `storeKey` is provided, the states will be stored with the following key: `${resource}.listParams`.

**Note:** Please note that selection state will remain linked to a resource-based key as described [here](./List.md#disablesyncwithlocation).

```jsx
// display the top 5 posts
<ListController
resource="posts"
storeKey="top"
sort={{ field: 'votes', order: 'DESC' }}
children={params => (
<ul>{
!params.isLoading && params.data.map(post => (
<li key={`post_${post.id}`}>{post.title} - {post.votes} votes</li>
))
}</ul>
)}
/>
```
slax57 marked this conversation as resolved.
Show resolved Hide resolved

## Return Value

The return value of `useListController` has the following shape:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ describe('useListController', () => {
clock.uninstall();
});
});

describe('showFilter', () => {
it('Does not remove previously shown filter when adding a new one', async () => {
let currentDisplayedFilters;
Expand Down Expand Up @@ -409,6 +410,7 @@ describe('useListController', () => {
});
});
});

describe('getListControllerProps', () => {
it('should only pick the props injected by the ListController', () => {
expect(
Expand Down Expand Up @@ -449,6 +451,7 @@ describe('useListController', () => {
});
});
});

describe('sanitizeListRestProps', () => {
it('should omit the props injected by the ListController', () => {
expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as React from 'react';
slax57 marked this conversation as resolved.
Show resolved Hide resolved
import {
render,
fireEvent,
screen,
waitFor,
act,
} from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { ListsUsingSameResource } from './useListController.storeKey.stories';

describe('useListController', () => {
describe('customStoreKey', () => {
it('should keep distinct two lists of the same resource given different keys', async () => {
render(
<ListsUsingSameResource
history={createMemoryHistory({
initialEntries: ['/top'],
})}
/>
);

await waitFor(() => {
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('flop'));
});
expect(
screen.getByLabelText('perPage').getAttribute('data-value')
).toEqual('3');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 { ListController } from './ListController';
import { ListControllerResult } from './useListController';

export default {
title: 'ra-core/controller/list/useListController',
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 listControllerComponent = (storeKey: string, sort?: SortPayload) => {
return (
<ListController
resource="posts"
debounce={200}
perPage={3}
sort={sort}
storeKey={storeKey}
children={(params: ListControllerResult) => (
<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 />
<br />
<button
aria-label="incrementPerPage"
disabled={params.perPage > params.data?.length ?? false}
onClick={() => params.setPerPage(++params.perPage)}
>
Increment perPage
</button>{' '}
<button
aria-label="decrementPerPage"
disabled={params.perPage <= 0}
onClick={() => params.setPerPage(--params.perPage)}
>
Decrement perPage
</button>
<ul style={styles.ul}>
{!params.isLoading &&
params.data.map(post => (
<li key={`post_${post.id}`}>
{post.title} - {post.votes} votes
</li>
))}
</ul>
</div>
)}
/>
);
};

const MinimalLayout = (props: CoreLayoutProps) => {
return (
<div style={styles.mainContainer}>
<Link aria-label="top" to={`/top`}>
Go to Top List
</Link>{' '}
<Link aria-label="flop" to={`/flop`}>
Go to Flop List
</Link>
<br />
<br />
{props.children}
</div>
);
};
const TopList = () =>
listControllerComponent('top', { field: 'votes', order: 'DESC' });
const FlopList = () =>
listControllerComponent('flop', { field: 'votes', order: 'ASC' });

export const ListsUsingSameResource = (argsOrProps, context) => {
const history = context?.history || argsOrProps.history;
return (
<CoreAdminContext
history={history}
store={localStorageStore()}
slax57 marked this conversation as resolved.
Show resolved Hide resolved
dataProvider={dataProvider}
>
<CoreAdminUI layout={MinimalLayout}>
<CustomRoutes>
<Route path="/top" element={<TopList />} />
</CustomRoutes>
<CustomRoutes>
<Route path="/flop" element={<FlopList />} />
</CustomRoutes>
<Resource name="posts" />
</CoreAdminUI>
</CoreAdminContext>
);
};
19 changes: 11 additions & 8 deletions packages/ra-core/src/controller/list/useListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ export const useListController = <RecordType extends RaRecord = any>(
props: ListControllerProps<RecordType> = {}
): ListControllerResult<RecordType> => {
const {
debounce = 500,
disableAuthentication,
disableSyncWithLocation,
exporter = defaultExporter,
filter,
filterDefaultValues,
sort = defaultSort,
perPage = 10,
filter,
debounce = 500,
disableSyncWithLocation,
queryOptions = {},
sort = defaultSort,
storeKey,
} = props;
useAuthenticated({ enabled: !disableAuthentication });
const resource = useResourceContext(props);
Expand All @@ -62,12 +63,13 @@ export const useListController = <RecordType extends RaRecord = any>(
const notify = useNotify();

const [query, queryModifiers] = useListParams({
resource,
filterDefaultValues,
sort,
perPage,
debounce,
disableSyncWithLocation,
filterDefaultValues,
perPage,
resource,
sort,
storeKey,
});

const [selectedIds, selectionModifiers] = useRecordSelection(resource);
Expand Down Expand Up @@ -195,6 +197,7 @@ export interface ListControllerProps<RecordType extends RaRecord = any> {
}> & { meta?: any };
resource?: string;
sort?: SortPayload;
storeKey?: string;
}

const defaultSort = {
Expand Down
24 changes: 14 additions & 10 deletions packages/ra-core/src/controller/list/useListParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,27 @@ export interface ListParams {
* } = listParamsActions;
*/
export const useListParams = ({
resource,
filterDefaultValues,
sort = defaultSort,
perPage = 10,
debounce = 500,
disableSyncWithLocation = false,
filterDefaultValues,
perPage = 10,
resource,
sort = defaultSort,
storeKey,
}: ListParamsOptions): [Parameters, Modifiers] => {
if (!storeKey) storeKey = `${resource}.listParams`;
slax57 marked this conversation as resolved.
Show resolved Hide resolved

const location = useLocation();
const navigate = useNavigate();
const [localParams, setLocalParams] = useState(defaultParams);
const storeKey = `${resource}.listParams`;
const [params, setParams] = useStore(storeKey, defaultParams);
const tempParams = useRef<ListParams>();
const isMounted = useIsMounted();

const requestSignature = [
location.search,
resource,
storeKey,
JSON.stringify(disableSyncWithLocation ? localParams : params),
JSON.stringify(filterDefaultValues),
JSON.stringify(sort),
Expand Down Expand Up @@ -367,15 +370,16 @@ export const getNumberOrDefault = (
};

export interface ListParamsOptions {
resource: string;
perPage?: number;
sort?: SortPayload;
// default value for a filter when displayed but not yet set
filterDefaultValues?: FilterPayload;
debounce?: number;
// Whether to disable the synchronization of the list parameters with
// the current location (URL search parameters)
disableSyncWithLocation?: boolean;
// default value for a filter when displayed but not yet set
filterDefaultValues?: FilterPayload;
perPage?: number;
resource: string;
sort?: SortPayload;
storeKey?: string;
}

interface Parameters extends ListParams {
Expand Down