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

List - Optional Sync With Location #5741

Merged
merged 4 commits into from
Jan 11, 2021
Merged
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
24 changes: 24 additions & 0 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Here are all the props accepted by the `<List>` component:
* [`pagination`](#pagination)
* [`aside`](#aside-component)
* [`empty`](#empty-page)
- [`syncWithLocation`](#synchronize-with-url)

Here is the minimal code necessary to display a list of posts:

Expand Down Expand Up @@ -721,6 +722,29 @@ const PostList = props => (

The default value for the `component` prop is `Card`.

## Synchronize With URL

When a List based component (eg: `PostList`) is passed to the `list` prop of a `<Resource>`, it will automatically synchronize its parameters with the browser URL (using react-router location). However, when used anywhere outside of a `<Resource>`, it won't synchronize, which can be useful when you have multiple lists on a single page for example.

In order to enable the synchronization with the URL, you can set the `syncWithLocation` prop. For example, adding a `List` to an `Edit` page:

```jsx
const TagsEdit = (props) => (
<>
<Edit {...props}>
// ...
</Edit>
<ResourceProviderContext resource="posts">
<List syncWithLocation basePath="/posts" filter={{ tags: [id]}}>
<Datagri>
<TextField source="title" />
</Datagrid>
</List>
</ResourceProviderContext>
</>
)
```

### CSS API

The `List` component accepts the usual `className` prop but you can override many class names injected to the inner components by React-admin thanks to the `classes` property (as most Material UI components, see their [documentation about it](https://material-ui.com/customization/components/#overriding-styles-with-classes)). This property accepts the following keys:
Expand Down
45 changes: 37 additions & 8 deletions examples/simple/src/tags/TagEdit.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
/* eslint react/jsx-key: off */
import * as React from 'react';
import { Edit, SimpleForm, TextField, TextInput, required } from 'react-admin';
import {
Edit,
SimpleForm,
TextField,
TextInput,
required,
List,
Datagrid,
ResourceContextProvider,
EditButton,
} from 'react-admin';

const TagEdit = props => (
<Edit {...props}>
<SimpleForm redirect="list">
<TextField source="id" />
<TextInput source="name" validate={[required()]} />
</SimpleForm>
</Edit>
<>
<Edit {...props}>
<SimpleForm redirect="list">
<TextField source="id" />
<TextInput source="name" validate={[required()]} />
</SimpleForm>
</Edit>
<ResourceContextProvider resource="posts">
<List
hasCreate={false}
hasShow
hasEdit
hasList
basePath="/posts"
resource="posts"
filter={{ tags: [props.id] }}
title=" "
>
<Datagrid>
<TextField source="id" />
<TextField source="title" />
<EditButton />
</Datagrid>
</List>
</ResourceContextProvider>
</>
);

export default TagEdit;
8 changes: 6 additions & 2 deletions packages/ra-core/src/controller/ListBase.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import useListController from './useListController';
import { ReactNode } from 'react';
import useListController, { ListProps } from './useListController';
import ListContextProvider from './ListContextProvider';

/**
Expand Down Expand Up @@ -36,7 +37,10 @@ import ListContextProvider from './ListContextProvider';
* </BaseList>
* );
*/
const ListBase = ({ children, ...props }) => (
const ListBase = ({
children,
...props
}: ListProps & { children: ReactNode }) => (
<ListContextProvider value={useListController(props)}>
{children}
</ListContextProvider>
Expand Down
18 changes: 14 additions & 4 deletions packages/ra-core/src/controller/useListController.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('useListController', () => {
};

const { getByLabelText, dispatch, reduxStore } = renderWithRedux(
<ListController {...props} />,
<ListController syncWithLocation {...props} />,
{
admin: {
resources: {
Expand Down Expand Up @@ -112,7 +112,7 @@ describe('useListController', () => {
};

const { getByLabelText, dispatch, reduxStore } = renderWithRedux(
<ListController {...props} />,
<ListController syncWithLocation {...props} />,
{
admin: {
resources: {
Expand Down Expand Up @@ -158,7 +158,11 @@ describe('useListController', () => {
};

const { dispatch, rerender } = renderWithRedux(
<ListController {...props} filter={{ foo: 1 }} />,
<ListController
syncWithLocation
{...props}
filter={{ foo: 1 }}
/>,
{
admin: {
resources: {
Expand Down Expand Up @@ -186,7 +190,13 @@ describe('useListController', () => {
// Check that the permanent filter is not included in the filterValues (passed to Filter form and button)
expect(children.mock.calls[0][0].filterValues).toEqual({});

rerender(<ListController {...props} filter={{ foo: 2 }} />);
rerender(
<ListController
syncWithLocation
{...props}
filter={{ foo: 2 }}
/>
);

const updatedCrudGetListCalls = dispatch.mock.calls.filter(
call => call[0].type === 'RA/CRUD_GET_LIST'
Expand Down
7 changes: 5 additions & 2 deletions packages/ra-core/src/controller/useListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface ListProps {
location?: Location;
path?: string;
resource?: string;
// Wether to synchronize the list parameters with the current location (URL search parameters)
// This is set to true automatically when a List is used inside a Resource component
syncWithLocation: boolean;
[key: string]: any;
}

Expand Down Expand Up @@ -117,6 +120,7 @@ const useListController = <RecordType extends Record = Record>(
perPage = 10,
filter,
debounce = 500,
syncWithLocation,
} = props;
const resource = useResourceContext(props);

Expand All @@ -126,17 +130,16 @@ const useListController = <RecordType extends Record = Record>(
);
}

const location = useLocation();
const translate = useTranslate();
const notify = useNotify();

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

const [selectedIds, selectionModifiers] = useRecordSelection(resource);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { getQuery, getNumberOrDefault } from './useListParams';
import * as React from 'react';
import useListParams, { getQuery, getNumberOrDefault } from './useListParams';
import {
SORT_DESC,
SORT_ASC,
} from '../reducer/admin/resource/list/queryReducer';
import { createMemoryHistory } from 'history';
import { renderWithRedux, TestContext } from '../util';
import { fireEvent, waitFor } from '@testing-library/react';

describe('useListParams', () => {
describe('getQuery', () => {
Expand Down Expand Up @@ -182,4 +186,61 @@ describe('useListParams', () => {
expect(result).toEqual(0);
});
});

describe('useListParams', () => {
const Component = ({ syncWithLocation = false }) => {
const [, { setPage }] = useListParams({
resource: 'posts',
syncWithLocation,
});

const handleClick = () => {
setPage(10);
};

return <button onClick={handleClick}>update</button>;
};

test('should synchronize parameters with location and redux state when sync is enabled', async () => {
const history = createMemoryHistory();
jest.spyOn(history, 'push');
let dispatch;

const { getByText } = renderWithRedux(
<TestContext enableReducers history={history}>
{({ store }) => {
dispatch = jest.spyOn(store, 'dispatch');
return <Component syncWithLocation />;
}}
</TestContext>
);

fireEvent.click(getByText('update'));

expect(history.push).toHaveBeenCalled();
expect(dispatch).toHaveBeenCalled();
});

test('should not synchronize parameters with location and redux state when sync is not enabled', async () => {
const history = createMemoryHistory();
jest.spyOn(history, 'push');
let dispatch;

const { getByText } = renderWithRedux(
<TestContext enableReducers history={history}>
{({ store }) => {
dispatch = jest.spyOn(store, 'dispatch');
return <Component />;
}}
</TestContext>
);

fireEvent.click(getByText('update'));

await waitFor(() => {
expect(history.push).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
});
});
});
44 changes: 28 additions & 16 deletions packages/ra-core/src/controller/useListParams.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useCallback, useMemo, useEffect } from 'react';
import { useCallback, useMemo, useEffect, useState } from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { parse, stringify } from 'query-string';
import lodashDebounce from 'lodash/debounce';
import set from 'lodash/set';
import pickBy from 'lodash/pickBy';
import { Location } from 'history';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';

import queryReducer, {
SET_FILTER,
Expand All @@ -21,14 +20,16 @@ import removeKey from '../util/removeKey';

interface ListParamsOptions {
resource: string;
location: Location;
perPage?: number;
sort?: SortPayload;
// default value for a filter when displayed but not yet set
filterDefaultValues?: FilterPayload;
// permanent filter which always overrides the user entry
filter?: FilterPayload;
debounce?: number;
// Wether to synchronize the list parameters with the current location (URL search parameters)
// This is set to true automatically when a List is used inside a Resource component
syncWithLocation?: boolean;
}

interface Parameters extends ListParams {
Expand Down Expand Up @@ -109,15 +110,17 @@ const defaultParams = {};
*/
const useListParams = ({
resource,
location,
filterDefaultValues,
filter, // permanent filter
sort = defaultSort,
perPage = 10,
debounce = 500,
syncWithLocation = false,
}: ListParamsOptions): [Parameters, Modifiers] => {
const dispatch = useDispatch();
const location = useLocation();
const history = useHistory();
const [localParams, setLocalParams] = useState(defaultParams);
const params = useSelector(
(reduxState: ReduxState) =>
reduxState.admin.resources[resource]
Expand All @@ -129,19 +132,22 @@ const useListParams = ({
const requestSignature = [
location.search,
resource,
params,
syncWithLocation ? params : localParams,
filterDefaultValues,
JSON.stringify(sort),
perPage,
syncWithLocation,
];

const queryFromLocation = parseQueryFromLocation(location);
const queryFromLocation = syncWithLocation
? parseQueryFromLocation(location)
: {};

const query = useMemo(
() =>
getQuery({
queryFromLocation,
params,
params: syncWithLocation ? params : localParams,
filterDefaultValues,
sort,
perPage,
Expand All @@ -161,14 +167,20 @@ const useListParams = ({

const changeParams = useCallback(action => {
const newParams = queryReducer(query, action);
history.push({
search: `?${stringify({
...newParams,
filter: JSON.stringify(newParams.filter),
displayedFilters: JSON.stringify(newParams.displayedFilters),
})}`,
});
dispatch(changeListParams(resource, newParams));
if (syncWithLocation) {
history.push({
search: `?${stringify({
...newParams,
filter: JSON.stringify(newParams.filter),
displayedFilters: JSON.stringify(
newParams.displayedFilters
),
})}`,
});
dispatch(changeListParams(resource, newParams));
} else {
setLocalParams(newParams);
}
}, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps

const setSort = useCallback(
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/core/Resource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const ResourceRoutes: FunctionComponent<ResourceProps> = ({
basePath={basePath}
{...routeProps}
{...resourceData}
syncWithLocation
/>
)}
/>
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/util/TestContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type ChildrenFunction = ({
interface Props {
initialState?: object;
enableReducers?: boolean;
history?: History;
children: ReactNode | ChildrenFunction;
}

Expand Down
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/src/list/List.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('<List />', () => {
history: {} as any,
location: {} as any,
match: (() => {}) as any,
syncWithLocation: true,
};

const defaultStateForList = {
Expand Down
Loading