diff --git a/packages/ra-core/src/controller/ListController.spec.tsx b/packages/ra-core/src/controller/ListController.spec.tsx index 85b2e4c568d..1d63ba1116b 100644 --- a/packages/ra-core/src/controller/ListController.spec.tsx +++ b/packages/ra-core/src/controller/ListController.spec.tsx @@ -5,7 +5,6 @@ import TextField from '@material-ui/core/TextField/TextField'; import ListController, { getListControllerProps, - getQuery, sanitizeListRestProps, } from './ListController'; @@ -227,159 +226,4 @@ describe('ListController', () => { }); }); }); - describe('getQuery', () => { - it('Returns the values from the location first', () => { - const query = getQuery({ - location: { - search: `?page=3&perPage=15&sort=name&order=ASC&filter=${JSON.stringify( - { name: 'marmelab' } - )}`, - }, - params: { - page: 1, - perPage: 10, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: 'DESC', - }, - }); - - expect(query).toEqual({ - page: '3', - perPage: '15', - sort: 'name', - order: 'ASC', - filter: { - name: 'marmelab', - }, - }); - }); - it('Extends the values from the location with those from the props', () => { - const query = getQuery({ - location: { - search: `?filter=${JSON.stringify({ name: 'marmelab' })}`, - }, - params: { - page: 1, - perPage: 10, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: 'DESC', - }, - }); - - expect(query).toEqual({ - page: 1, - perPage: 50, - sort: 'company', - order: 'DESC', - filter: { - name: 'marmelab', - }, - }); - }); - it('Sets the values from the redux store if location does not have them', () => { - const query = getQuery({ - location: { - search: ``, - }, - params: { - page: 2, - perPage: 10, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: 'DESC', - }, - }); - - expect(query).toEqual({ - page: 2, - perPage: 10, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }); - }); - it('Extends the values from the redux store with those from the props', () => { - const query = getQuery({ - location: { - search: ``, - }, - params: { - page: 2, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }, - filterDefaultValues: {}, - perPage: 50, - sort: { - field: 'company', - order: 'DESC', - }, - }); - - expect(query).toEqual({ - page: 2, - perPage: 50, - sort: 'city', - order: 'DESC', - filter: { - city: 'Dijon', - }, - }); - }); - it('Uses the filterDefaultValues if neither the location or the redux store have them', () => { - const query = getQuery({ - location: { - search: ``, - }, - params: {}, - filterDefaultValues: { city: 'Nancy' }, - perPage: 50, - sort: { - field: 'company', - order: 'DESC', - }, - }); - - expect(query).toEqual({ - page: 1, - perPage: 50, - sort: 'company', - order: 'DESC', - filter: { - city: 'Nancy', - }, - }); - }); - }); }); diff --git a/packages/ra-core/src/controller/ListController.tsx b/packages/ra-core/src/controller/ListController.tsx index 8ee73b6fe50..3538aec5677 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -3,34 +3,19 @@ import { ReactNode, ReactElement, useCallback, - useState, useEffect, } from 'react'; // @ts-ignore import { useSelector, useDispatch } from 'react-redux'; -import { parse, stringify } from 'query-string'; -import { push } from 'connected-react-router'; import inflection from 'inflection'; -import lodashDebounce from 'lodash/debounce'; -import isEqual from 'lodash/isEqual'; -import pickBy from 'lodash/pickBy'; -import removeEmpty from '../util/removeEmpty'; -import queryReducer, { - SET_SORT, - SET_PAGE, - SET_PER_PAGE, - SET_FILTER, - SORT_ASC, -} from '../reducer/admin/resource/list/queryReducer'; +import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { crudGetList } from '../actions/dataActions'; import { - changeListParams, setListSelectedIds, toggleListItem, ListParams, } from '../actions/listActions'; -import removeKey from '../util/removeKey'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Sort, @@ -42,6 +27,7 @@ import { } from '../types'; import { Location } from 'history'; import { useTranslate } from '../i18n'; +import useListParams from './useListParams'; interface ChildrenFuncParams { basePath: string; @@ -160,7 +146,6 @@ const ListController = (props: Props) => { debounce = 500, } = props; - const [displayedFilters, setDisplayedFilters] = useState({}); const dispatch = useDispatch(); const translate = useTranslate(); const isLoading = useSelector( @@ -171,131 +156,67 @@ const ListController = (props: Props) => { (reduxState: ReduxState) => reduxState.admin.ui.viewVersion ); - const { params, ids, loadedOnce, selectedIds, total } = useSelector( + const { loadedOnce, selectedIds } = useSelector( (reduxState: ReduxState) => reduxState.admin.resources[resource].list, [resource] ); - const data = useSelector( - (reduxState: ReduxState) => reduxState.admin.resources[resource].data, - [resource] - ); + if (filter && isValidElement(filter)) { + throw new Error( + ' received a React element as `filter` props. If you intended to set the list filter elements, use the `filters` (with an s) prop instead. The `filter` prop is internal and should not be set by the developer.' + ); + } - const query = getQuery({ + const [query, actions] = useListParams({ + resource, location, - params, filterDefaultValues, sort, perPage, + debounce, }); - const requestSignature = [ - resource, - JSON.stringify(query), - JSON.stringify(location), - ]; - - const changeParams = useCallback(action => { - const newParams = queryReducer(query, action); + useEffect(() => { + const pagination = { + page: query.page, + perPage: query.perPage, + }; + const permanentFilter = filter; dispatch( - push({ - ...location, - search: `?${stringify({ - ...newParams, - filter: JSON.stringify(newParams.filter), - })}`, - }) + crudGetList( + resource, + pagination, + { field: query.sort, order: query.order }, + { ...query.filter, ...permanentFilter } + ) ); - dispatch(changeListParams(resource, newParams)); - }, requestSignature); - - const setSort = useCallback( - newSort => changeParams({ type: SET_SORT, payload: { sort: newSort } }), - requestSignature - ); + }, query.requestSignature); - const setPage = useCallback( - newPage => changeParams({ type: SET_PAGE, payload: newPage }), - requestSignature + const data = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].data, + [resource] ); - const setPerPage = useCallback( - newPerPage => changeParams({ type: SET_PER_PAGE, payload: newPerPage }), - requestSignature + const { ids, total } = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].list, + [resource] ); - if (filter && isValidElement(filter)) { - throw new Error( - ' received a React element as `filter` props. If you intended to set the list filter elements, use the `filters` (with an s) prop instead. The `filter` prop is internal and should not be set by the developer.' - ); - } - - if (!query.page && !(ids || []).length && params.page > 1 && total > 0) { - setPage(params.page - 1); - return null; + if (!query.page && !(ids || []).length && query.page > 1 && total > 0) { + actions.setPage(query.page - 1); } - const filterValues = query.filter || {}; - - const setFilters = useCallback( - lodashDebounce(filters => { - if (isEqual(filters, filterValues)) { - return; - } - - // fix for redux-form bug with onChange and enableReinitialize - const filtersWithoutEmpty = removeEmpty(filters); - changeParams({ - type: SET_FILTER, - payload: filtersWithoutEmpty, - }); - }, debounce), - requestSignature - ); - - const hideFilter = useCallback((filterName: string) => { - setDisplayedFilters({ [filterName]: false }); - const newFilters = removeKey(filterValues, filterName); - setFilters(newFilters); - }, requestSignature); - - const showFilter = useCallback((filterName: string, defaultValue: any) => { - setDisplayedFilters({ [filterName]: true }); - if (typeof defaultValue !== 'undefined') { - setFilters({ - ...filterValues, - [filterName]: defaultValue, - }); - } - }, requestSignature); - const handleSelect = useCallback((newIds: Identifier[]) => { dispatch(setListSelectedIds(resource, newIds)); - }, requestSignature); + }, query.requestSignature); const handleUnselectItems = useCallback(() => { dispatch(setListSelectedIds(resource, [])); - }, requestSignature); + }, query.requestSignature); const handleToggleItem = useCallback((id: Identifier) => { dispatch(toggleListItem(resource, id)); - }, requestSignature); - - useEffect(() => { - const pagination = { - page: query.page, - perPage: query.perPage, - }; - const permanentFilter = filter; - dispatch( - crudGetList( - resource, - pagination, - { field: query.sort, order: query.order }, - { ...query.filter, ...permanentFilter } - ) - ); - }, requestSignature); + }, query.requestSignature); const resourceName = translate(`resources.${resource}.name`, { smart_count: 2, @@ -313,8 +234,8 @@ const ListController = (props: Props) => { }, data, defaultTitle, - displayedFilters, - filterValues, + displayedFilters: query.displayedFilters, + filterValues: query.filterValues, hasCreate, ids, isLoading, @@ -322,106 +243,22 @@ const ListController = (props: Props) => { onSelect: handleSelect, onToggleItem: handleToggleItem, onUnselectItems: handleUnselectItems, - page: getNumberOrDefault(query.page, 1), - perPage: getNumberOrDefault(query.perPage, 10), + page: query.page, + perPage: query.perPage, resource, selectedIds, - setFilters, - hideFilter, - showFilter, - setPage, - setPerPage, - setSort, + setFilters: actions.setFilters, + hideFilter: actions.hideFilter, + showFilter: actions.showFilter, + setPage: actions.setPage, + setPerPage: actions.setPerPage, + setSort: actions.setSort, translate, total, version, }); }; -export const validQueryParams = ['page', 'perPage', 'sort', 'order', 'filter']; - -export const parseQueryFromLocation = ({ search }) => { - const query = pickBy( - parse(search), - (v, k) => validQueryParams.indexOf(k) !== -1 - ); - if (query.filter && typeof query.filter === 'string') { - try { - query.filter = JSON.parse(query.filter); - } catch (err) { - delete query.filter; - } - } - return query; -}; - -/** - * Check if user has already set custom sort, page, or filters for this list - * - * User params come from the Redux store as the params props. By default, - * this object is: - * - * { filter: {}, order: null, page: 1, perPage: null, sort: null } - * - * To check if the user has custom params, we must compare the params - * to these initial values. - * - * @param {object} params - */ -export const hasCustomParams = (params: ListParams) => { - return ( - params && - params.filter && - (Object.keys(params.filter).length > 0 || - params.order != null || - params.page !== 1 || - params.perPage != null || - params.sort != null) - ); -}; - -/** - * Merge list params from 3 different sources: - * - the query string - * - the params stored in the state (from previous navigation) - * - the props passed to the List component (including the filter defaultValues) - */ -export const getQuery = ({ - location, - params, - filterDefaultValues, - sort, - perPage, -}) => { - const queryFromLocation = parseQueryFromLocation(location); - const query: Partial = - Object.keys(queryFromLocation).length > 0 - ? queryFromLocation - : hasCustomParams(params) - ? { ...params } - : { filter: filterDefaultValues || {} }; - - if (!query.sort) { - query.sort = sort.field; - query.order = sort.order; - } - if (!query.perPage) { - query.perPage = perPage; - } - if (!query.page) { - query.page = 1; - } - return query as ListParams; -}; - -export const getNumberOrDefault = ( - possibleNumber: string | number | undefined, - defaultValue: number -) => - (typeof possibleNumber === 'string' - ? parseInt(possibleNumber, 10) - : possibleNumber) || defaultValue; - export const injectedProps = [ 'basePath', 'currentSort', diff --git a/packages/ra-core/src/controller/useListParams.spec.ts b/packages/ra-core/src/controller/useListParams.spec.ts new file mode 100644 index 00000000000..e44146a5616 --- /dev/null +++ b/packages/ra-core/src/controller/useListParams.spec.ts @@ -0,0 +1,163 @@ +import { getQuery } from './useListParams'; +import { + SORT_DESC, + SORT_ASC, +} from '../reducer/admin/resource/list/queryReducer'; + +describe('ListController', () => { + describe('getQuery', () => { + it('Returns the values from the location first', () => { + const query = getQuery({ + location: { + search: `?page=3&perPage=15&sort=name&order=ASC&filter=${JSON.stringify( + { 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({ + location: { + search: `?filter=${JSON.stringify({ 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({ + location: { + search: ``, + }, + 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({ + location: { + search: ``, + }, + 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({ + location: { + search: ``, + }, + 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', + }, + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts new file mode 100644 index 00000000000..75b1b21b1be --- /dev/null +++ b/packages/ra-core/src/controller/useListParams.ts @@ -0,0 +1,285 @@ +import { useCallback, useState } from 'react'; +// @ts-ignore +import { useSelector, useDispatch } from 'react-redux'; +import { parse, stringify } from 'query-string'; +import { push } from 'connected-react-router'; +import lodashDebounce from 'lodash/debounce'; +import isEqual from 'lodash/isEqual'; +import pickBy from 'lodash/pickBy'; +import { Location } from 'history'; + +import queryReducer, { + SET_FILTER, + SET_PAGE, + SET_PER_PAGE, + SET_SORT, + SORT_ASC, +} from '../reducer/admin/resource/list/queryReducer'; +import { changeListParams, ListParams } from '../actions/listActions'; +import { Sort, ReduxState, Identifier, RecordMap } from '../types'; +import removeEmpty from '../util/removeEmpty'; +import removeKey from '../util/removeKey'; + +interface Props { + filterDefaultValues?: object; + perPage?: number; + sort?: Sort; + location: Location; + resource: string; + debounce?: number; +} + +interface Query extends ListParams { + filterValues: object; + displayedFilters: { + [key: string]: boolean; + }; + requestSignature: any[]; +} + +interface Actions { + changeParams: (action: any) => void; + setPage: (page: number) => void; + setPerPage: (pageSize: number) => void; + setSort: (sort: Sort) => void; + setFilters: (filters: any) => void; + hideFilter: (filterName: string) => void; + showFilter: (filterName: string, defaultValue: any) => void; +} + +/** + * Returns an array (like useState) with the list params as the first element and actions to modify them in the second. + * + * @example + * const [listParams, listParamsActions] = useListParams({ + * resource: 'posts', + * location: location // From react-router. Injected to your component by react-admin inside a List + * filterDefaultValues: { + * published: true + * }, + * sort: { + * field: 'published_at', + * order: 'DESC' + * }, + * perPage: 25 + * }); + * + * const { + * page, + * perPage, + * sort, + * order, + * filter, + * filterValues, + * displayedFilters, + * requestSignature + * } = listParams; + * + * const { + * setFilters, + * hideFilter, + * showFilter, + * setPage, + * setPerPage, + * setSort, + * } = listParamsActions; + */ +const useListParams = ({ + resource, + location, + filterDefaultValues, + sort = { + field: 'id', + order: SORT_ASC, + }, + perPage = 10, + debounce = 500, +}: Props): [Query, Actions] => { + const [displayedFilters, setDisplayedFilters] = useState({}); + const dispatch = useDispatch(); + + const { params } = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].list, + [resource] + ); + + const query = getQuery({ + location, + params, + filterDefaultValues, + sort, + perPage, + }); + + const requestSignature = [resource, JSON.stringify(query)]; + + const changeParams = useCallback(action => { + const newParams = queryReducer(query, action); + dispatch( + push({ + search: `?${stringify({ + ...newParams, + filter: JSON.stringify(newParams.filter), + })}`, + }) + ); + dispatch(changeListParams(resource, newParams)); + }, requestSignature); + + const setSort = useCallback( + newSort => changeParams({ type: SET_SORT, payload: { sort: newSort } }), + requestSignature + ); + + const setPage = useCallback( + newPage => changeParams({ type: SET_PAGE, payload: newPage }), + requestSignature + ); + + const setPerPage = useCallback( + newPerPage => changeParams({ type: SET_PER_PAGE, payload: newPerPage }), + requestSignature + ); + + const filterValues = query.filter || {}; + + const setFilters = useCallback( + lodashDebounce(filters => { + if (isEqual(filters, filterValues)) { + return; + } + + // fix for redux-form bug with onChange and enableReinitialize + const filtersWithoutEmpty = removeEmpty(filters); + changeParams({ + type: SET_FILTER, + payload: filtersWithoutEmpty, + }); + }, debounce), + requestSignature + ); + + const hideFilter = useCallback((filterName: string) => { + setDisplayedFilters({ [filterName]: false }); + const newFilters = removeKey(filterValues, filterName); + setFilters(newFilters); + }, requestSignature); + + const showFilter = useCallback((filterName: string, defaultValue: any) => { + setDisplayedFilters({ [filterName]: true }); + if (typeof defaultValue !== 'undefined') { + setFilters({ + ...filterValues, + [filterName]: defaultValue, + }); + } + }, requestSignature); + + return [ + { + displayedFilters, + filterValues, + requestSignature, + ...query, + }, + { + changeParams, + setPage, + setPerPage, + setSort, + setFilters, + hideFilter, + showFilter, + }, + ]; +}; + +export const validQueryParams = ['page', 'perPage', 'sort', 'order', 'filter']; + +export const parseQueryFromLocation = ({ search }) => { + const query = pickBy( + parse(search), + (v, k) => validQueryParams.indexOf(k) !== -1 + ); + if (query.filter && typeof query.filter === 'string') { + try { + query.filter = JSON.parse(query.filter); + } catch (err) { + delete query.filter; + } + } + return query; +}; + +/** + * Check if user has already set custom sort, page, or filters for this list + * + * User params come from the Redux store as the params props. By default, + * this object is: + * + * { filter: {}, order: null, page: 1, perPage: null, sort: null } + * + * To check if the user has custom params, we must compare the params + * to these initial values. + * + * @param {object} params + */ +export const hasCustomParams = (params: ListParams) => { + return ( + params && + params.filter && + (Object.keys(params.filter).length > 0 || + params.order != null || + params.page !== 1 || + params.perPage != null || + params.sort != null) + ); +}; + +/** + * Merge list params from 3 different sources: + * - the query string + * - the params stored in the state (from previous navigation) + * - the props passed to the List component (including the filter defaultValues) + */ +export const getQuery = ({ + location, + params, + filterDefaultValues, + sort, + perPage, +}) => { + const queryFromLocation = parseQueryFromLocation(location); + const query: Partial = + Object.keys(queryFromLocation).length > 0 + ? queryFromLocation + : hasCustomParams(params) + ? { ...params } + : { filter: filterDefaultValues || {} }; + + if (!query.sort) { + query.sort = sort.field; + query.order = sort.order; + } + if (!query.perPage) { + query.perPage = perPage; + } + if (!query.page) { + query.page = 1; + } + return { + ...query, + page: getNumberOrDefault(query.page, 1), + perPage: getNumberOrDefault(query.perPage, 10), + } as ListParams; +}; + +export const getNumberOrDefault = ( + possibleNumber: string | number | undefined, + defaultValue: number +) => + (typeof possibleNumber === 'string' + ? parseInt(possibleNumber, 10) + : possibleNumber) || defaultValue; + +export default useListParams;