From 65747092827dee3809308570c2bd6abab5f3b790 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 5 Jan 2021 14:12:52 +0100 Subject: [PATCH 1/4] List - Optional Sync With Location --- docs/List.md | 24 ++++++++++ examples/simple/src/tags/TagEdit.js | 45 +++++++++++++++---- packages/ra-core/src/controller/ListBase.tsx | 8 +++- .../src/controller/useListController.ts | 5 +++ .../ra-core/src/controller/useListParams.ts | 26 +++++++---- packages/ra-core/src/core/Resource.tsx | 1 + .../ra-ui-materialui/src/list/List.spec.tsx | 1 + packages/ra-ui-materialui/src/types.ts | 1 + 8 files changed, 93 insertions(+), 18 deletions(-) diff --git a/docs/List.md b/docs/List.md index 3541759f312..8859a67d2e0 100644 --- a/docs/List.md +++ b/docs/List.md @@ -27,6 +27,7 @@ Here are all the props accepted by the `` 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: @@ -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 ``, it will automatically synchronize its parameters with the browser URL (using react-router location). However, when used anywhere outside of a ``, 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) => ( + <> + + // ... + + + + + + + + + +) +``` + ### 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: diff --git a/examples/simple/src/tags/TagEdit.js b/examples/simple/src/tags/TagEdit.js index 9b622c7c7e3..1adf2848bf3 100644 --- a/examples/simple/src/tags/TagEdit.js +++ b/examples/simple/src/tags/TagEdit.js @@ -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 => ( - - - - - - + <> + + + + + + + + + + + + + + + + ); - export default TagEdit; diff --git a/packages/ra-core/src/controller/ListBase.tsx b/packages/ra-core/src/controller/ListBase.tsx index 63ca5e8897a..02ffc3285d0 100644 --- a/packages/ra-core/src/controller/ListBase.tsx +++ b/packages/ra-core/src/controller/ListBase.tsx @@ -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'; /** @@ -36,7 +37,10 @@ import ListContextProvider from './ListContextProvider'; * * ); */ -const ListBase = ({ children, ...props }) => ( +const ListBase = ({ + children, + ...props +}: ListProps & { children: ReactNode }) => ( {children} diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index cebd36ea9bc..34d260360f1 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -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; } @@ -117,6 +120,7 @@ const useListController = ( perPage = 10, filter, debounce = 500, + syncWithLocation, } = props; const resource = useResourceContext(props); @@ -137,6 +141,7 @@ const useListController = ( sort, perPage, debounce, + syncWithLocation, }); const [selectedIds, selectionModifiers] = useRecordSelection(resource); diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts index 48a18febd68..d0a912fe634 100644 --- a/packages/ra-core/src/controller/useListParams.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -29,6 +29,9 @@ interface ListParamsOptions { // 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 { @@ -115,6 +118,7 @@ const useListParams = ({ sort = defaultSort, perPage = 10, debounce = 500, + syncWithLocation, }: ListParamsOptions): [Parameters, Modifiers] => { const dispatch = useDispatch(); const history = useHistory(); @@ -135,7 +139,9 @@ const useListParams = ({ perPage, ]; - const queryFromLocation = parseQueryFromLocation(location); + const queryFromLocation = syncWithLocation + ? parseQueryFromLocation(location) + : {}; const query = useMemo( () => @@ -161,13 +167,17 @@ 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), - })}`, - }); + if (syncWithLocation) { + history.push({ + search: `?${stringify({ + ...newParams, + filter: JSON.stringify(newParams.filter), + displayedFilters: JSON.stringify( + newParams.displayedFilters + ), + })}`, + }); + } dispatch(changeListParams(resource, newParams)); }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/packages/ra-core/src/core/Resource.tsx b/packages/ra-core/src/core/Resource.tsx index 47bfb7db76a..d1ccc879c6e 100644 --- a/packages/ra-core/src/core/Resource.tsx +++ b/packages/ra-core/src/core/Resource.tsx @@ -129,6 +129,7 @@ const ResourceRoutes: FunctionComponent = ({ basePath={basePath} {...routeProps} {...resourceData} + syncWithLocation /> )} /> diff --git a/packages/ra-ui-materialui/src/list/List.spec.tsx b/packages/ra-ui-materialui/src/list/List.spec.tsx index 4f0335a4574..47fffa9e114 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.tsx +++ b/packages/ra-ui-materialui/src/list/List.spec.tsx @@ -22,6 +22,7 @@ describe('', () => { history: {} as any, location: {} as any, match: (() => {}) as any, + syncWithLocation: true, }; const defaultStateForList = { diff --git a/packages/ra-ui-materialui/src/types.ts b/packages/ra-ui-materialui/src/types.ts index e75fd0143d2..76418378800 100644 --- a/packages/ra-ui-materialui/src/types.ts +++ b/packages/ra-ui-materialui/src/types.ts @@ -24,6 +24,7 @@ export interface ListProps extends ResourceComponentProps { pagination?: ReactElement | false; perPage?: number; sort?: SortPayload; + syncWithLocation: boolean; title?: string | ReactElement; } From b90ecd78c9094ea7a2da9a028246125f6e9171d2 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 5 Jan 2021 15:27:39 +0100 Subject: [PATCH 2/4] Don't sync with redux either --- .../src/controller/useListController.ts | 2 -- .../ra-core/src/controller/useListParams.ts | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index 34d260360f1..08cb9f610ce 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -130,13 +130,11 @@ const useListController = ( ); } - const location = useLocation(); const translate = useTranslate(); const notify = useNotify(); const [query, queryModifiers] = useListParams({ resource, - location, filterDefaultValues, sort, perPage, diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts index d0a912fe634..03e6c9a668f 100644 --- a/packages/ra-core/src/controller/useListParams.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -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, @@ -21,7 +20,6 @@ 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 @@ -112,16 +110,17 @@ const defaultParams = {}; */ const useListParams = ({ resource, - location, filterDefaultValues, filter, // permanent filter sort = defaultSort, perPage = 10, debounce = 500, - syncWithLocation, + 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] @@ -133,10 +132,11 @@ const useListParams = ({ const requestSignature = [ location.search, resource, - params, + syncWithLocation ? params : localParams, filterDefaultValues, JSON.stringify(sort), perPage, + syncWithLocation, ]; const queryFromLocation = syncWithLocation @@ -147,7 +147,7 @@ const useListParams = ({ () => getQuery({ queryFromLocation, - params, + params: syncWithLocation ? params : localParams, filterDefaultValues, sort, perPage, @@ -177,8 +177,10 @@ const useListParams = ({ ), })}`, }); + dispatch(changeListParams(resource, newParams)); + } else { + setLocalParams(newParams); } - dispatch(changeListParams(resource, newParams)); }, requestSignature); // eslint-disable-line react-hooks/exhaustive-deps const setSort = useCallback( From 14736f2fb1cf66de209c77a8b4c86c2daa12a74d Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 5 Jan 2021 16:31:34 +0100 Subject: [PATCH 3/4] Add tests --- .../src/controller/useListController.spec.tsx | 18 +- .../src/controller/useListParams.spec.ts | 185 ------------------ .../ra-core/src/controller/useListParams.ts | 2 +- packages/ra-core/src/util/TestContext.tsx | 1 + 4 files changed, 16 insertions(+), 190 deletions(-) delete mode 100644 packages/ra-core/src/controller/useListParams.spec.ts diff --git a/packages/ra-core/src/controller/useListController.spec.tsx b/packages/ra-core/src/controller/useListController.spec.tsx index 84248dc6eb7..801bd9fb37f 100644 --- a/packages/ra-core/src/controller/useListController.spec.tsx +++ b/packages/ra-core/src/controller/useListController.spec.tsx @@ -68,7 +68,7 @@ describe('useListController', () => { }; const { getByLabelText, dispatch, reduxStore } = renderWithRedux( - , + , { admin: { resources: { @@ -112,7 +112,7 @@ describe('useListController', () => { }; const { getByLabelText, dispatch, reduxStore } = renderWithRedux( - , + , { admin: { resources: { @@ -158,7 +158,11 @@ describe('useListController', () => { }; const { dispatch, rerender } = renderWithRedux( - , + , { admin: { resources: { @@ -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(); + rerender( + + ); const updatedCrudGetListCalls = dispatch.mock.calls.filter( call => call[0].type === 'RA/CRUD_GET_LIST' diff --git a/packages/ra-core/src/controller/useListParams.spec.ts b/packages/ra-core/src/controller/useListParams.spec.ts deleted file mode 100644 index e878d49b665..00000000000 --- a/packages/ra-core/src/controller/useListParams.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { getQuery, getNumberOrDefault } from './useListParams'; -import { - SORT_DESC, - SORT_ASC, -} from '../reducer/admin/resource/list/queryReducer'; - -describe('useListParams', () => { - describe('getQuery', () => { - it('Returns the values from the location first', () => { - const query = getQuery({ - queryFromLocation: { - page: 3, - perPage: 15, - sort: 'name', - order: SORT_ASC, - filter: { name: 'marmelab' }, - }, - params: { - page: 1, - perPage: 10, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: SORT_DESC, - }, - }); - - expect(query).toEqual({ - page: 3, - perPage: 15, - sort: 'name', - order: SORT_ASC, - filter: { - name: 'marmelab', - }, - }); - }); - it('Extends the values from the location with those from the props', () => { - const query = getQuery({ - queryFromLocation: { - filter: { name: 'marmelab' }, - }, - params: { - page: 1, - perPage: 10, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: SORT_DESC, - }, - }); - - expect(query).toEqual({ - page: 1, - perPage: 50, - sort: 'company', - order: SORT_DESC, - filter: { - name: 'marmelab', - }, - }); - }); - it('Sets the values from the redux store if location does not have them', () => { - const query = getQuery({ - queryFromLocation: {}, - params: { - page: 2, - perPage: 10, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: SORT_DESC, - }, - }); - - expect(query).toEqual({ - page: 2, - perPage: 10, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }); - }); - it('Extends the values from the redux store with those from the props', () => { - const query = getQuery({ - queryFromLocation: {}, - params: { - page: 2, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: SORT_DESC, - }, - }); - - expect(query).toEqual({ - page: 2, - perPage: 50, - sort: 'city', - order: SORT_DESC, - filter: { - city: 'Dijon', - }, - }); - }); - it('Uses the filterDefaultValues if neither the location or the redux store have them', () => { - const query = getQuery({ - queryFromLocation: {}, - params: {}, - filterDefaultValues: { city: 'Nancy' }, - perPage: 50, - sort: { - field: 'company', - order: SORT_DESC, - }, - }); - - expect(query).toEqual({ - page: 1, - perPage: 50, - sort: 'company', - order: SORT_DESC, - filter: { - city: 'Nancy', - }, - }); - }); - }); - - describe('getNumberOrDefault', () => { - it('should return the parsed number', () => { - const result = getNumberOrDefault('29', 2); - - expect(result).toEqual(29); - }); - - it('should return the default number when passing a not valid number', () => { - const result = getNumberOrDefault('covfefe', 2); - - expect(result).toEqual(2); - }); - - it('should return the default number when passing an undefined number', () => { - const result = getNumberOrDefault(undefined, 2); - - expect(result).toEqual(2); - }); - - it('should not return the default number when passing "0"', () => { - const result = getNumberOrDefault('0', 2); - - expect(result).toEqual(0); - }); - }); -}); diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts index 03e6c9a668f..007aaaaaa40 100644 --- a/packages/ra-core/src/controller/useListParams.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -29,7 +29,7 @@ interface ListParamsOptions { 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; + syncWithLocation?: boolean; } interface Parameters extends ListParams { diff --git a/packages/ra-core/src/util/TestContext.tsx b/packages/ra-core/src/util/TestContext.tsx index 2d611c95080..2ff32e123ad 100644 --- a/packages/ra-core/src/util/TestContext.tsx +++ b/packages/ra-core/src/util/TestContext.tsx @@ -29,6 +29,7 @@ type ChildrenFunction = ({ interface Props { initialState?: object; enableReducers?: boolean; + history?: History; children: ReactNode | ChildrenFunction; } From e11f3892e7a56a63e6267a43d984b3cdbf235c20 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:54:53 +0100 Subject: [PATCH 4/4] Missing tests --- .../src/controller/useListParams.spec.tsx | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 packages/ra-core/src/controller/useListParams.spec.tsx diff --git a/packages/ra-core/src/controller/useListParams.spec.tsx b/packages/ra-core/src/controller/useListParams.spec.tsx new file mode 100644 index 00000000000..28b88f25090 --- /dev/null +++ b/packages/ra-core/src/controller/useListParams.spec.tsx @@ -0,0 +1,246 @@ +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', () => { + it('Returns the values from the location first', () => { + const query = getQuery({ + queryFromLocation: { + page: 3, + perPage: 15, + sort: 'name', + order: SORT_ASC, + filter: { name: 'marmelab' }, + }, + params: { + page: 1, + perPage: 10, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }, + filterDefaultValues: {}, + perPage: 50, + sort: { + field: 'company', + order: SORT_DESC, + }, + }); + + expect(query).toEqual({ + page: 3, + perPage: 15, + sort: 'name', + order: SORT_ASC, + filter: { + name: 'marmelab', + }, + }); + }); + it('Extends the values from the location with those from the props', () => { + const query = getQuery({ + queryFromLocation: { + filter: { name: 'marmelab' }, + }, + params: { + page: 1, + perPage: 10, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }, + filterDefaultValues: {}, + perPage: 50, + sort: { + field: 'company', + order: SORT_DESC, + }, + }); + + expect(query).toEqual({ + page: 1, + perPage: 50, + sort: 'company', + order: SORT_DESC, + filter: { + name: 'marmelab', + }, + }); + }); + it('Sets the values from the redux store if location does not have them', () => { + const query = getQuery({ + queryFromLocation: {}, + params: { + page: 2, + perPage: 10, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }, + filterDefaultValues: {}, + perPage: 50, + sort: { + field: 'company', + order: SORT_DESC, + }, + }); + + expect(query).toEqual({ + page: 2, + perPage: 10, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }); + }); + it('Extends the values from the redux store with those from the props', () => { + const query = getQuery({ + queryFromLocation: {}, + params: { + page: 2, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }, + filterDefaultValues: {}, + perPage: 50, + sort: { + field: 'company', + order: SORT_DESC, + }, + }); + + expect(query).toEqual({ + page: 2, + perPage: 50, + sort: 'city', + order: SORT_DESC, + filter: { + city: 'Dijon', + }, + }); + }); + it('Uses the filterDefaultValues if neither the location or the redux store have them', () => { + const query = getQuery({ + queryFromLocation: {}, + params: {}, + filterDefaultValues: { city: 'Nancy' }, + perPage: 50, + sort: { + field: 'company', + order: SORT_DESC, + }, + }); + + expect(query).toEqual({ + page: 1, + perPage: 50, + sort: 'company', + order: SORT_DESC, + filter: { + city: 'Nancy', + }, + }); + }); + }); + + describe('getNumberOrDefault', () => { + it('should return the parsed number', () => { + const result = getNumberOrDefault('29', 2); + + expect(result).toEqual(29); + }); + + it('should return the default number when passing a not valid number', () => { + const result = getNumberOrDefault('covfefe', 2); + + expect(result).toEqual(2); + }); + + it('should return the default number when passing an undefined number', () => { + const result = getNumberOrDefault(undefined, 2); + + expect(result).toEqual(2); + }); + + it('should not return the default number when passing "0"', () => { + const result = getNumberOrDefault('0', 2); + + expect(result).toEqual(0); + }); + }); + + describe('useListParams', () => { + const Component = ({ syncWithLocation = false }) => { + const [, { setPage }] = useListParams({ + resource: 'posts', + syncWithLocation, + }); + + const handleClick = () => { + setPage(10); + }; + + return ; + }; + + 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( + + {({ store }) => { + dispatch = jest.spyOn(store, 'dispatch'); + return ; + }} + + ); + + 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( + + {({ store }) => { + dispatch = jest.spyOn(store, 'dispatch'); + return ; + }} + + ); + + fireEvent.click(getByText('update')); + + await waitFor(() => { + expect(history.push).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + }); +});