UNPKG

ra-core

Version:

Core components of react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React

414 lines (381 loc) 12.4 kB
import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import { parse, stringify } from 'query-string'; import lodashDebounce from 'lodash/debounce'; import { useNavigate, useLocation } from 'react-router-dom'; import { useStore } from '../../store'; import queryReducer, { SET_FILTER, HIDE_FILTER, SHOW_FILTER, SET_PAGE, SET_PER_PAGE, SET_SORT, SORT_ASC, } from './queryReducer'; import { SortPayload, FilterPayload } from '../../types'; import removeEmpty from '../../util/removeEmpty'; import { useIsMounted } from '../../util/hooks'; export interface ListParams { sort: string; order: 'ASC' | 'DESC'; page: number; perPage: number; filter: any; displayedFilters: any; } /** * Get the list parameters (page, sort, filters) and modifiers. * * These parameters are merged from 3 sources: * - the query string from the URL * - the params stored in the state (from previous navigation) * - the options passed to the hook (including the filter defaultValues) * * @returns {Array} A tuple [parameters, modifiers]. * Destructure as [ * { page, perPage, sort, order, filter, filterValues, displayedFilters, requestSignature }, * { setFilters, hideFilter, showFilter, setPage, setPerPage, setSort } * ] * * @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; */ export const useListParams = ({ debounce = 500, disableSyncWithLocation = false, filterDefaultValues, perPage = 10, resource, sort = defaultSort, storeKey = disableSyncWithLocation ? false : `${resource}.listParams`, }: ListParamsOptions): [Parameters, Modifiers] => { const location = useLocation(); const navigate = useNavigate(); const [localParams, setLocalParams] = useState(defaultParams); // As we can't conditionally call a hook, if the storeKey is false, // we'll ignore the params variable later on and won't call setParams either. const [params, setParams] = useStore( storeKey || `${resource}.listParams`, defaultParams ); const tempParams = useRef<ListParams>(); const isMounted = useIsMounted(); const requestSignature = [ location.search, resource, storeKey, JSON.stringify(!storeKey ? localParams : params), JSON.stringify(filterDefaultValues), JSON.stringify(sort), perPage, disableSyncWithLocation, ]; const queryFromLocation = disableSyncWithLocation ? {} : parseQueryFromLocation(location); const query = useMemo( () => getQuery({ queryFromLocation, params: !storeKey ? localParams : params, filterDefaultValues, sort, perPage, }), requestSignature // eslint-disable-line react-hooks/exhaustive-deps ); // if the location includes params (for example from a link like // the categories products on the demo), we need to persist them in the // store as well so that we don't lose them after a redirection back // to the list useEffect(() => { if (Object.keys(queryFromLocation).length > 0) { setParams(query); } }, [location.search]); // eslint-disable-line const changeParams = useCallback( action => { // do not change params if the component is already unmounted // this is necessary because changeParams can be debounced, and therefore // executed after the component is unmounted if (!isMounted.current) return; if (!tempParams.current) { // no other changeParams action dispatched this tick tempParams.current = queryReducer(query, action); // schedule side effects for next tick setTimeout(() => { if (!tempParams.current) { // the side effects were already processed by another changeParams return; } if (disableSyncWithLocation && !storeKey) { setLocalParams(tempParams.current); } else if (disableSyncWithLocation && !!storeKey) { setParams(tempParams.current); } else { // the useEffect above will apply the changes to the params in the store navigate( { search: `?${stringify({ ...tempParams.current, filter: JSON.stringify( tempParams.current.filter ), displayedFilters: JSON.stringify( tempParams.current.displayedFilters ), })}`, }, { state: { _scrollToTop: action.type === SET_PAGE, }, } ); } tempParams.current = undefined; }, 0); } else { // side effects already scheduled, just change the params tempParams.current = queryReducer(tempParams.current, action); } }, [...requestSignature, navigate] // eslint-disable-line react-hooks/exhaustive-deps ); const setSort = useCallback( (sort: SortPayload) => changeParams({ type: SET_SORT, payload: sort, }), [changeParams] ); const setPage = useCallback( (newPage: number) => changeParams({ type: SET_PAGE, payload: newPage }), [changeParams] ); const setPerPage = useCallback( (newPerPage: number) => changeParams({ type: SET_PER_PAGE, payload: newPerPage }), [changeParams] ); const filterValues = query.filter || emptyObject; const displayedFilterValues = query.displayedFilters || emptyObject; const debouncedSetFilters = lodashDebounce((filter, displayedFilters) => { changeParams({ type: SET_FILTER, payload: { filter: removeEmpty(filter), displayedFilters, }, }); }, debounce); const setFilters = useCallback( (filter, displayedFilters = undefined, debounce = false) => debounce ? debouncedSetFilters(filter, displayedFilters) : changeParams({ type: SET_FILTER, payload: { filter: removeEmpty(filter), displayedFilters, }, }), [changeParams] // eslint-disable-line react-hooks/exhaustive-deps ); const hideFilter = useCallback( (filterName: string) => { changeParams({ type: HIDE_FILTER, payload: filterName, }); }, [changeParams] ); const showFilter = useCallback( (filterName: string, defaultValue: any) => { changeParams({ type: SHOW_FILTER, payload: { filterName, defaultValue, }, }); }, [changeParams] ); return [ { filterValues, requestSignature, ...query, displayedFilters: displayedFilterValues, }, { changeParams, setPage, setPerPage, setSort, setFilters, hideFilter, showFilter, }, ]; }; const parseObject = (query, field) => { if (query[field] && typeof query[field] === 'string') { try { query[field] = JSON.parse(query[field]); } catch (err) { delete query[field]; } } }; export const parseQueryFromLocation = ({ search }): Partial<ListParams> => { const query = parse(search); parseObject(query, 'filter'); parseObject(query, 'displayedFilters'); return query; }; /** * Check if user has already set custom sort, page, or filters for this list * * User params come from the 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 = ({ queryFromLocation, params, filterDefaultValues, sort, perPage, }) => { const query: Partial<ListParams> = 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 == null) { query.perPage = perPage; } if (query.page == null) { 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 ) => { if (typeof possibleNumber === 'undefined') { return defaultValue; } const parsedNumber = typeof possibleNumber === 'string' ? parseInt(possibleNumber, 10) : possibleNumber; return isNaN(parsedNumber) ? defaultValue : parsedNumber; }; export interface ListParamsOptions { debounce?: number; // Whether to disable the synchronization of the list parameters with // the current location (URL search parameters) disableSyncWithLocation?: boolean; // default value for a filter when displayed but not yet set filterDefaultValues?: FilterPayload; perPage?: number; resource: string; sort?: SortPayload; storeKey?: string | false; } interface Parameters extends ListParams { filterValues: object; displayedFilters: { [key: string]: boolean; }; requestSignature: any[]; } interface Modifiers { changeParams: (action: any) => void; setPage: (page: number) => void; setPerPage: (pageSize: number) => void; setSort: (sort: SortPayload) => void; setFilters: ( filters: any, displayedFilters?: any, debounce?: boolean ) => void; hideFilter: (filterName: string) => void; showFilter: (filterName: string, defaultValue: any) => void; } const emptyObject = {}; const defaultSort = { field: 'id', order: SORT_ASC, } as const; const defaultParams = {};