From af1bacaa4ddf6ecdb4a43d565d527e9b4cd0aa77 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Mon, 13 May 2019 19:22:31 +0200 Subject: [PATCH 1/4] [WIP] Extract custom hooks from controllers --- .../src/controller/ListController.spec.tsx | 156 ------------ .../ra-core/src/controller/ListController.tsx | 193 ++------------- .../ra-core/src/controller/useList.spec.ts | 163 ++++++++++++ packages/ra-core/src/controller/useList.ts | 233 ++++++++++++++++++ 4 files changed, 421 insertions(+), 324 deletions(-) create mode 100644 packages/ra-core/src/controller/useList.spec.ts create mode 100644 packages/ra-core/src/controller/useList.ts 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..ca84f8725ed 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -42,6 +42,7 @@ import { } from '../types'; import { Location } from 'history'; import { useTranslate } from '../i18n'; +import useList from './useList'; interface ChildrenFuncParams { basePath: string; @@ -171,69 +172,25 @@ 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] - ); - - const query = getQuery({ - location, - params, - filterDefaultValues, - sort, - perPage, - }); - - const requestSignature = [ - resource, - JSON.stringify(query), - JSON.stringify(location), - ]; - - const changeParams = useCallback(action => { - const newParams = queryReducer(query, action); - dispatch( - push({ - ...location, - 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 - ); - 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; - } + const [query, actions] = useList({ + resource, + location, + filterDefaultValues, + sort, + perPage, + filter, + }); const filterValues = query.filter || {}; @@ -245,19 +202,19 @@ const ListController = (props: Props) => { // fix for redux-form bug with onChange and enableReinitialize const filtersWithoutEmpty = removeEmpty(filters); - changeParams({ + actions.changeParams({ type: SET_FILTER, payload: filtersWithoutEmpty, }); }, debounce), - requestSignature + query.requestSignature ); const hideFilter = useCallback((filterName: string) => { setDisplayedFilters({ [filterName]: false }); const newFilters = removeKey(filterValues, filterName); setFilters(newFilters); - }, requestSignature); + }, query.requestSignature); const showFilter = useCallback((filterName: string, defaultValue: any) => { setDisplayedFilters({ [filterName]: true }); @@ -267,35 +224,19 @@ const ListController = (props: Props) => { [filterName]: defaultValue, }); } - }, requestSignature); + }, query.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, @@ -311,117 +252,33 @@ const ListController = (props: Props) => { field: query.sort, order: query.order, }, - data, + data: query.data, defaultTitle, displayedFilters, filterValues, hasCreate, - ids, + ids: query.ids, isLoading, loadedOnce, 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, + setPage: actions.setPage, + setPerPage: actions.setPerPage, + setSort: actions.setSort, translate, - total, + total: query.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/useList.spec.ts b/packages/ra-core/src/controller/useList.spec.ts new file mode 100644 index 00000000000..7d0cb9901ad --- /dev/null +++ b/packages/ra-core/src/controller/useList.spec.ts @@ -0,0 +1,163 @@ +import { getQuery } from './useList'; +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/useList.ts b/packages/ra-core/src/controller/useList.ts new file mode 100644 index 00000000000..9a50a54b6d3 --- /dev/null +++ b/packages/ra-core/src/controller/useList.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect } from 'react'; +// @ts-ignore +import { useSelector, useDispatch } from 'react-redux'; +import { parse, stringify } from 'query-string'; +import { push } from 'connected-react-router'; +import pickBy from 'lodash/pickBy'; + +import queryReducer, { + SET_SORT, + SET_PAGE, + SET_PER_PAGE, + SORT_ASC, +} from '../reducer/admin/resource/list/queryReducer'; +import { crudGetList } from '../actions/dataActions'; +import { changeListParams, ListParams } from '../actions/listActions'; +import { Sort, ReduxState, Identifier, RecordMap } from '../types'; +import { Location } from 'history'; + +interface Props { + filter?: object; + filterDefaultValues?: object; + perPage?: number; + sort?: Sort; + location: Location; + resource: string; +} + +interface Query extends ListParams { + data: RecordMap; + ids: Identifier[]; + total: number; + requestSignature: any[]; +} + +interface Actions { + changeParams: (action: any) => void; + setPage: (page: number) => void; + setPerPage: (pageSize: number) => void; + setSort: (sort: Sort) => void; +} + +const useList = ({ + resource, + location, + filterDefaultValues, + sort = { + field: 'id', + order: SORT_ASC, + }, + perPage = 10, + filter, +}: Props): [Query, Actions] => { + const dispatch = useDispatch(); + + const { params, ids, total } = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].list, + [resource] + ); + + const data = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].data, + [resource] + ); + + const query = getQuery({ + location, + params, + filterDefaultValues, + sort, + perPage, + }); + + const requestSignature = [ + resource, + JSON.stringify(query), + JSON.stringify(location), + ]; + + const changeParams = useCallback(action => { + const newParams = queryReducer(query, action); + dispatch( + push({ + ...location, + 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 + ); + + if (!query.page && !(ids || []).length && params.page > 1 && total > 0) { + setPage(params.page - 1); + } + + 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); + + return [ + { + data, + ids, + total, + requestSignature, + ...query, + }, + { + changeParams, + setPage, + setPerPage, + setSort, + }, + ]; +}; + +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 useList; From 999f171f3abe5d7d01fd26eb107afbc5212ec0a0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Tue, 14 May 2019 09:34:09 +0200 Subject: [PATCH 2/4] Review --- .../ra-core/src/controller/ListController.tsx | 76 +++---------------- ...{useList.spec.ts => useListParams.spec.ts} | 2 +- .../{useList.ts => useListParams.ts} | 71 ++++++++++++++--- 3 files changed, 71 insertions(+), 78 deletions(-) rename packages/ra-core/src/controller/{useList.spec.ts => useListParams.spec.ts} (99%) rename packages/ra-core/src/controller/{useList.ts => useListParams.ts} (74%) diff --git a/packages/ra-core/src/controller/ListController.tsx b/packages/ra-core/src/controller/ListController.tsx index ca84f8725ed..e7938d0d31a 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -1,36 +1,14 @@ -import { - isValidElement, - ReactNode, - ReactElement, - useCallback, - useState, - useEffect, -} from 'react'; +import { isValidElement, ReactNode, ReactElement, useCallback } 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 { crudGetList } from '../actions/dataActions'; +import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { - changeListParams, setListSelectedIds, toggleListItem, ListParams, } from '../actions/listActions'; -import removeKey from '../util/removeKey'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Sort, @@ -42,7 +20,7 @@ import { } from '../types'; import { Location } from 'history'; import { useTranslate } from '../i18n'; -import useList from './useList'; +import useListParams from './useListParams'; interface ChildrenFuncParams { basePath: string; @@ -161,7 +139,6 @@ const ListController = (props: Props) => { debounce = 500, } = props; - const [displayedFilters, setDisplayedFilters] = useState({}); const dispatch = useDispatch(); const translate = useTranslate(); const isLoading = useSelector( @@ -183,49 +160,16 @@ const ListController = (props: Props) => { ); } - const [query, actions] = useList({ + const [query, actions] = useListParams({ resource, location, filterDefaultValues, sort, perPage, filter, + debounce, }); - 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); - actions.changeParams({ - type: SET_FILTER, - payload: filtersWithoutEmpty, - }); - }, debounce), - query.requestSignature - ); - - const hideFilter = useCallback((filterName: string) => { - setDisplayedFilters({ [filterName]: false }); - const newFilters = removeKey(filterValues, filterName); - setFilters(newFilters); - }, query.requestSignature); - - const showFilter = useCallback((filterName: string, defaultValue: any) => { - setDisplayedFilters({ [filterName]: true }); - if (typeof defaultValue !== 'undefined') { - setFilters({ - ...filterValues, - [filterName]: defaultValue, - }); - } - }, query.requestSignature); - const handleSelect = useCallback((newIds: Identifier[]) => { dispatch(setListSelectedIds(resource, newIds)); }, query.requestSignature); @@ -254,8 +198,8 @@ const ListController = (props: Props) => { }, data: query.data, defaultTitle, - displayedFilters, - filterValues, + displayedFilters: query.displayedFilters, + filterValues: query.filterValues, hasCreate, ids: query.ids, isLoading, @@ -267,9 +211,9 @@ const ListController = (props: Props) => { perPage: query.perPage, resource, selectedIds, - setFilters, - hideFilter, - showFilter, + setFilters: actions.setFilters, + hideFilter: actions.hideFilter, + showFilter: actions.showFilter, setPage: actions.setPage, setPerPage: actions.setPerPage, setSort: actions.setSort, diff --git a/packages/ra-core/src/controller/useList.spec.ts b/packages/ra-core/src/controller/useListParams.spec.ts similarity index 99% rename from packages/ra-core/src/controller/useList.spec.ts rename to packages/ra-core/src/controller/useListParams.spec.ts index 7d0cb9901ad..e44146a5616 100644 --- a/packages/ra-core/src/controller/useList.spec.ts +++ b/packages/ra-core/src/controller/useListParams.spec.ts @@ -1,4 +1,4 @@ -import { getQuery } from './useList'; +import { getQuery } from './useListParams'; import { SORT_DESC, SORT_ASC, diff --git a/packages/ra-core/src/controller/useList.ts b/packages/ra-core/src/controller/useListParams.ts similarity index 74% rename from packages/ra-core/src/controller/useList.ts rename to packages/ra-core/src/controller/useListParams.ts index 9a50a54b6d3..7bae682c497 100644 --- a/packages/ra-core/src/controller/useList.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -1,20 +1,25 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, 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_SORT, + SET_FILTER, SET_PAGE, SET_PER_PAGE, + SET_SORT, SORT_ASC, } from '../reducer/admin/resource/list/queryReducer'; import { crudGetList } from '../actions/dataActions'; import { changeListParams, ListParams } from '../actions/listActions'; import { Sort, ReduxState, Identifier, RecordMap } from '../types'; -import { Location } from 'history'; +import removeEmpty from '../util/removeEmpty'; +import removeKey from '../util/removeKey'; interface Props { filter?: object; @@ -23,12 +28,17 @@ interface Props { sort?: Sort; location: Location; resource: string; + debounce?: number; } interface Query extends ListParams { data: RecordMap; + filterValues: object; ids: Identifier[]; total: number; + displayedFilters: { + [key: string]: boolean; + }; requestSignature: any[]; } @@ -37,9 +47,12 @@ interface Actions { 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; } -const useList = ({ +const useListParams = ({ resource, location, filterDefaultValues, @@ -49,7 +62,9 @@ const useList = ({ }, perPage = 10, filter, + debounce = 500, }: Props): [Query, Actions] => { + const [displayedFilters, setDisplayedFilters] = useState({}); const dispatch = useDispatch(); const { params, ids, total } = useSelector( @@ -70,17 +85,12 @@ const useList = ({ perPage, }); - const requestSignature = [ - resource, - JSON.stringify(query), - JSON.stringify(location), - ]; + const requestSignature = [resource, JSON.stringify(query)]; const changeParams = useCallback(action => { const newParams = queryReducer(query, action); dispatch( push({ - ...location, search: `?${stringify({ ...newParams, filter: JSON.stringify(newParams.filter), @@ -105,6 +115,40 @@ const useList = ({ 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); + if (!query.page && !(ids || []).length && params.page > 1 && total > 0) { setPage(params.page - 1); } @@ -128,6 +172,8 @@ const useList = ({ return [ { data, + displayedFilters, + filterValues, ids, total, requestSignature, @@ -138,6 +184,9 @@ const useList = ({ setPage, setPerPage, setSort, + setFilters, + hideFilter, + showFilter, }, ]; }; @@ -230,4 +279,4 @@ export const getNumberOrDefault = ( ? parseInt(possibleNumber, 10) : possibleNumber) || defaultValue; -export default useList; +export default useListParams; From 729bc1713703b924c6b874223fd52d9b8905813b Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Wed, 15 May 2019 09:10:30 +0200 Subject: [PATCH 3/4] Move data and fetching effect back to controller --- .../ra-core/src/controller/ListController.tsx | 33 +++++++++++++++++-- .../ra-core/src/controller/useListParams.ts | 26 --------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/ra-core/src/controller/ListController.tsx b/packages/ra-core/src/controller/ListController.tsx index e7938d0d31a..5819c001110 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -1,9 +1,16 @@ -import { isValidElement, ReactNode, ReactElement, useCallback } from 'react'; +import { + isValidElement, + ReactNode, + ReactElement, + useCallback, + useEffect, +} from 'react'; // @ts-ignore import { useSelector, useDispatch } from 'react-redux'; import inflection from 'inflection'; import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; +import { crudGetList } from '../actions/dataActions'; import { setListSelectedIds, toggleListItem, @@ -166,10 +173,30 @@ const ListController = (props: Props) => { filterDefaultValues, sort, perPage, - filter, debounce, }); + 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 } + ) + ); + }, query.requestSignature); + + const data = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].data, + [resource] + ); + const handleSelect = useCallback((newIds: Identifier[]) => { dispatch(setListSelectedIds(resource, newIds)); }, query.requestSignature); @@ -196,7 +223,7 @@ const ListController = (props: Props) => { field: query.sort, order: query.order, }, - data: query.data, + data, defaultTitle, displayedFilters: query.displayedFilters, filterValues: query.filterValues, diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts index 7bae682c497..8cdcb6e8f52 100644 --- a/packages/ra-core/src/controller/useListParams.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -15,14 +15,12 @@ import queryReducer, { SET_SORT, SORT_ASC, } from '../reducer/admin/resource/list/queryReducer'; -import { crudGetList } from '../actions/dataActions'; 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 { - filter?: object; filterDefaultValues?: object; perPage?: number; sort?: Sort; @@ -32,7 +30,6 @@ interface Props { } interface Query extends ListParams { - data: RecordMap; filterValues: object; ids: Identifier[]; total: number; @@ -61,7 +58,6 @@ const useListParams = ({ order: SORT_ASC, }, perPage = 10, - filter, debounce = 500, }: Props): [Query, Actions] => { const [displayedFilters, setDisplayedFilters] = useState({}); @@ -72,11 +68,6 @@ const useListParams = ({ [resource] ); - const data = useSelector( - (reduxState: ReduxState) => reduxState.admin.resources[resource].data, - [resource] - ); - const query = getQuery({ location, params, @@ -153,25 +144,8 @@ const useListParams = ({ setPage(params.page - 1); } - 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); - return [ { - data, displayedFilters, filterValues, ids, From 2b4d594b9f0f2ae06ed63bffb18d4b48c392de19 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Wed, 15 May 2019 09:30:28 +0200 Subject: [PATCH 4/4] Extract data related code from the hooks. Add doc --- .../ra-core/src/controller/ListController.tsx | 13 ++++- .../ra-core/src/controller/useListParams.ts | 49 +++++++++++++++---- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/ra-core/src/controller/ListController.tsx b/packages/ra-core/src/controller/ListController.tsx index 5819c001110..3538aec5677 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -197,6 +197,15 @@ const ListController = (props: Props) => { [resource] ); + const { ids, total } = useSelector( + (reduxState: ReduxState) => reduxState.admin.resources[resource].list, + [resource] + ); + + if (!query.page && !(ids || []).length && query.page > 1 && total > 0) { + actions.setPage(query.page - 1); + } + const handleSelect = useCallback((newIds: Identifier[]) => { dispatch(setListSelectedIds(resource, newIds)); }, query.requestSignature); @@ -228,7 +237,7 @@ const ListController = (props: Props) => { displayedFilters: query.displayedFilters, filterValues: query.filterValues, hasCreate, - ids: query.ids, + ids, isLoading, loadedOnce, onSelect: handleSelect, @@ -245,7 +254,7 @@ const ListController = (props: Props) => { setPerPage: actions.setPerPage, setSort: actions.setSort, translate, - total: query.total, + total, version, }); }; diff --git a/packages/ra-core/src/controller/useListParams.ts b/packages/ra-core/src/controller/useListParams.ts index 8cdcb6e8f52..75b1b21b1be 100644 --- a/packages/ra-core/src/controller/useListParams.ts +++ b/packages/ra-core/src/controller/useListParams.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; // @ts-ignore import { useSelector, useDispatch } from 'react-redux'; import { parse, stringify } from 'query-string'; @@ -31,8 +31,6 @@ interface Props { interface Query extends ListParams { filterValues: object; - ids: Identifier[]; - total: number; displayedFilters: { [key: string]: boolean; }; @@ -49,6 +47,43 @@ interface Actions { 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, @@ -63,7 +98,7 @@ const useListParams = ({ const [displayedFilters, setDisplayedFilters] = useState({}); const dispatch = useDispatch(); - const { params, ids, total } = useSelector( + const { params } = useSelector( (reduxState: ReduxState) => reduxState.admin.resources[resource].list, [resource] ); @@ -140,16 +175,10 @@ const useListParams = ({ } }, requestSignature); - if (!query.page && !(ids || []).length && params.page > 1 && total > 0) { - setPage(params.page - 1); - } - return [ { displayedFilters, filterValues, - ids, - total, requestSignature, ...query, },