From da5e1f4b3fa262b67513f741c954e0f464ff2fd0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 1 Jun 2021 11:52:25 +0200 Subject: [PATCH 1/4] Introduce useList --- .../ReferenceArrayFieldController.spec.tsx | 56 ++-- .../field/useReferenceArrayFieldController.ts | 178 +------------ .../ra-core/src/controller/useList.spec.tsx | 171 +++++++++++++ packages/ra-core/src/controller/useList.ts | 240 ++++++++++++++++++ packages/ra-core/src/util/hooks.ts | 2 +- .../ra-ui-materialui/src/list/ListView.tsx | 3 +- 6 files changed, 459 insertions(+), 191 deletions(-) create mode 100644 packages/ra-core/src/controller/useList.spec.tsx create mode 100644 packages/ra-core/src/controller/useList.ts diff --git a/packages/ra-core/src/controller/field/ReferenceArrayFieldController.spec.tsx b/packages/ra-core/src/controller/field/ReferenceArrayFieldController.spec.tsx index bcc68489cba..0f688b32cbe 100644 --- a/packages/ra-core/src/controller/field/ReferenceArrayFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceArrayFieldController.spec.tsx @@ -200,7 +200,7 @@ describe('', () => { }); }); - it('should filter string data based on the filter props', () => { + it('should filter string data based on the filter props', async () => { const children = jest.fn().mockReturnValue('child'); renderWithRedux( ', () => { }, } ); - expect(children.mock.calls[0][0]).toMatchObject({ - basePath: '/bar', - currentSort: { field: 'id', order: 'ASC' }, - loaded: true, - loading: true, - data: { - 2: { id: 2, title: 'world' }, - }, - ids: [1, 2], - error: null, + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + basePath: '/bar', + currentSort: { field: 'id', order: 'ASC' }, + loaded: true, + loading: true, + data: { + 2: { id: 2, title: 'world' }, + }, + ids: [2], + error: null, + }) + ); }); }); - it('should filter array data based on the filter props', () => { + it('should filter array data based on the filter props', async () => { const children = jest.fn().mockReturnValue('child'); renderWithRedux( ', () => { }, } ); - expect(children.mock.calls[0][0]).toMatchObject({ - basePath: '/bar', - currentSort: { field: 'id', order: 'ASC' }, - loaded: true, - loading: true, - data: { - 1: { id: 1, items: ['one', 'two'] }, - 3: { id: 3, items: 'four' }, - 4: { id: 4, items: ['five'] }, - }, - ids: [1, 2, 3, 4], - error: null, + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + basePath: '/bar', + currentSort: { field: 'id', order: 'ASC' }, + loaded: true, + loading: true, + data: { + 1: { id: 1, items: ['one', 'two'] }, + 3: { id: 3, items: 'four' }, + 4: { id: 4, items: ['five'] }, + }, + ids: [1, 3, 4], + error: null, + }) + ); }); }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts index 5aa4354be50..2eb48829a0f 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts @@ -1,17 +1,11 @@ -import { useCallback, useEffect, useRef } from 'react'; import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; -import { useSafeSetState, removeEmpty } from '../../util'; -import { Record, RecordMap, Identifier, SortPayload } from '../../types'; +import { Record, SortPayload } from '../../types'; import { useGetMany } from '../../dataProvider'; import { ListControllerProps } from '../useListController'; import { useNotify } from '../../sideEffect'; -import usePaginationState from '../usePaginationState'; -import useSelectionState from '../useSelectionState'; -import useSortState from '../useSortState'; import { useResourceContext } from '../../core'; -import { indexById } from '../../util/indexById'; +import { useList } from '../useList'; interface Option { basePath?: string; @@ -92,173 +86,27 @@ const useReferenceArrayFieldController = ( } ); - const [loadingState, setLoadingState] = useSafeSetState(loading); - const [loadedState, setLoadedState] = useSafeSetState(loaded); - - const [finalData, setFinalData] = useSafeSetState( - indexById(data) - ); - const [finalIds, setFinalIds] = useSafeSetState(ids); - - // pagination logic - const { page, setPage, perPage, setPerPage } = usePaginationState({ - page: initialPage, - perPage: initialPerPage, - }); - - // sort logic - const { sort, setSort: setSortObject } = useSortState(initialSort); - const setSort = useCallback( - (field: string, order: string = 'ASC') => { - setSortObject({ field, order }); - setPage(1); - }, - [setPage, setSortObject] - ); - - // selection logic - const { - selectedIds, - onSelect, - onToggleItem, - onUnselectItems, - } = useSelectionState(); - - // filter logic - const filterRef = useRef(filter); - const [displayedFilters, setDisplayedFilters] = useSafeSetState<{ - [key: string]: boolean; - }>({}); - const [filterValues, setFilterValues] = useSafeSetState<{ - [key: string]: any; - }>(filter); - const hideFilter = useCallback( - (filterName: string) => { - setDisplayedFilters(previousState => { - const { [filterName]: _, ...newState } = previousState; - return newState; - }); - setFilterValues(previousState => { - const { [filterName]: _, ...newState } = previousState; - return newState; - }); - }, - [setDisplayedFilters, setFilterValues] - ); - const showFilter = useCallback( - (filterName: string, defaultValue: any) => { - setDisplayedFilters(previousState => ({ - ...previousState, - [filterName]: true, - })); - setFilterValues(previousState => ({ - ...previousState, - [filterName]: defaultValue, - })); - }, - [setDisplayedFilters, setFilterValues] - ); - const setFilters = useCallback( - (filters, displayedFilters) => { - setFilterValues(removeEmpty(filters)); - setDisplayedFilters(displayedFilters); - setPage(1); - }, - [setDisplayedFilters, setFilterValues, setPage] - ); - // handle filter prop change - useEffect(() => { - if (!isEqual(filter, filterRef.current)) { - filterRef.current = filter; - setFilterValues(filter); - } - }); - - // We do all the data processing (filtering, sorting, paginating) client-side - useEffect(() => { - if (!loaded) return; - // 1. filter - let tempData = data.filter(record => - Object.entries(filterValues).every(([filterName, filterValue]) => - Array.isArray(get(record, filterName)) - ? get(record, filterName).includes(filterValue) - : // eslint-disable-next-line eqeqeq - filterValue == get(record, filterName) - ) - ); - // 2. sort - if (sort.field) { - tempData = tempData.sort((a, b) => { - if (get(a, sort.field) > get(b, sort.field)) { - return sort.order === 'ASC' ? 1 : -1; - } - if (get(a, sort.field) < get(b, sort.field)) { - return sort.order === 'ASC' ? -1 : 1; - } - return 0; - }); - } - // 3. paginate - tempData = tempData.slice((page - 1) * perPage, page * perPage); - setFinalData(indexById(tempData)); - setFinalIds( - tempData - .filter(data => typeof data !== 'undefined') - .map(data => data.id) - ); - }, [ - data, - filterValues, + const listProps = useList({ + error, + filter, + initialData: data, + initialIds: ids, + initialPage, + initialPerPage, + initialSort, + loading, loaded, - page, - perPage, - setFinalData, - setFinalIds, - sort.field, - sort.order, - ]); - - useEffect(() => { - if (loaded !== loadedState) { - setLoadedState(loaded); - } - }, [loaded, loadedState, setLoadedState]); - - useEffect(() => { - if (loading !== loadingState) { - setLoadingState(loading); - } - }, [loading, loadingState, setLoadingState]); + }); return { basePath: basePath ? basePath.replace(resource, reference) : `/${reference}`, - currentSort: sort, - data: finalData, + ...listProps, defaultTitle: null, - error, - displayedFilters, - filterValues, hasCreate: false, - hideFilter, - ids: finalIds, - loaded: loadedState, - loading: loadingState, - onSelect, - onToggleItem, - onUnselectItems, - page, - perPage, refetch, resource: reference, - selectedIds, - setFilters, - setPage, - setPerPage, - setSort, - showFilter, - total: finalIds.length, }; }; diff --git a/packages/ra-core/src/controller/useList.spec.tsx b/packages/ra-core/src/controller/useList.spec.tsx new file mode 100644 index 00000000000..673f39b88a1 --- /dev/null +++ b/packages/ra-core/src/controller/useList.spec.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import expect from 'expect'; + +import { useList, UseListOptions, UseListValue } from './useList'; +import { render, waitFor } from '@testing-library/react'; + +const UseList = ({ + callback, + ...props +}: UseListOptions & { callback: (value: UseListValue) => void }) => { + const value = useList(props); + callback(value); + return null; +}; + +describe('', () => { + it('should filter string data based on the filter props', () => { + const callback = jest.fn(); + const data = [ + { id: 1, title: 'hello' }, + { id: 2, title: 'world' }, + ]; + const ids = [1, 2]; + + render( + + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + currentSort: { field: 'id', order: 'ASC' }, + loaded: true, + loading: true, + data: { + 2: { id: 2, title: 'world' }, + }, + ids: [2], + error: undefined, + }) + ); + }); + + it('should filter array data based on the filter props', async () => { + const callback = jest.fn(); + const data = [ + { id: 1, items: ['one', 'two'] }, + { id: 2, items: ['three'] }, + { id: 3, items: 'four' }, + { id: 4, items: ['five'] }, + ]; + const ids = [1, 2, 3, 4]; + + render( + + ); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + currentSort: { field: 'id', order: 'ASC' }, + loaded: true, + loading: true, + data: { + 1: { id: 1, items: ['one', 'two'] }, + 3: { id: 3, items: 'four' }, + 4: { id: 4, items: ['five'] }, + }, + ids: [1, 3, 4], + error: undefined, + }) + ); + }); + }); + + it('should apply sorting correctly', async () => { + const callback = jest.fn(); + const data = [ + { id: 1, title: 'hello' }, + { id: 2, title: 'world' }, + ]; + const ids = [1, 2]; + + render( + + ); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + currentSort: { field: 'title', order: 'DESC' }, + loaded: true, + loading: true, + data: { + 2: { id: 2, title: 'world' }, + 1: { id: 1, title: 'hello' }, + }, + ids: [2, 1], + error: undefined, + }) + ); + }); + }); + + it('should apply pagination correctly', async () => { + const callback = jest.fn(); + const data = [ + { id: 1, title: 'hello' }, + { id: 2, title: 'world' }, + { id: 3, title: 'baz' }, + { id: 4, title: 'bar' }, + { id: 5, title: 'foo' }, + { id: 6, title: 'plop' }, + { id: 7, title: 'bazinga' }, + ]; + const ids = [1, 2, 3, 4, 5, 6, 7]; + + render( + + ); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + currentSort: { field: 'id', order: 'ASC' }, + loaded: true, + loading: true, + data: { + 6: { id: 6, title: 'plop' }, + 7: { id: 7, title: 'bazinga' }, + }, + ids: [6, 7], + page: 2, + perPage: 5, + error: undefined, + }) + ); + }); + }); +}); diff --git a/packages/ra-core/src/controller/useList.ts b/packages/ra-core/src/controller/useList.ts new file mode 100644 index 00000000000..ee1fecb319a --- /dev/null +++ b/packages/ra-core/src/controller/useList.ts @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef } from 'react'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import { indexById, removeEmpty, useSafeSetState } from '../util'; +import { Identifier, Record, RecordMap, SortPayload } from '../types'; +import usePaginationState from './usePaginationState'; +import useSortState from './useSortState'; +import useSelectionState from './useSelectionState'; +import { ListControllerProps } from '.'; + +/** + * Hook that applies list filtering, sorting and pagination on the provided data, either in memory or through the provided function. + * + * @example + * const data = [ + * { id: 1, name: 'Arnold' }, + * { id: 2, name: 'Sylvester' }, + * { id: 3, name: 'Jean-Claude' }, + * ] + * const { ids, data, error, loaded, loading } = useList({ + * ids: providedIds, + * data: providedData, + * basePath: '/resource'; + * resource: 'resource'; + * }); + * + * @param {Object} props + * @param {string} props.basePath basepath to current resource + * @param {string} props.resource The current resource name + */ +export const useList = (props: UseListOptions): UseListValue => { + const { + data, + error, + filter = defaultFilter, + ids, + initialData, + initialIds, + loaded, + loading, + initialPage = 1, + initialPerPage = 1000, + initialSort = defaultSort, + total, + } = props; + const [loadingState, setLoadingState] = useSafeSetState(loading); + const [loadedState, setLoadedState] = useSafeSetState(loaded); + + const [finalItems, setFinalItems] = useSafeSetState<{ + data: RecordMap; + ids: Identifier[]; + }>(() => ({ + data: indexById(initialData), + ids: initialIds, + })); + + // pagination logic + const { page, setPage, perPage, setPerPage } = usePaginationState({ + page: initialPage, + perPage: initialPerPage, + }); + + // sort logic + const { sort, setSort: setSortObject } = useSortState(initialSort); + const setSort = useCallback( + (field: string, order = 'ASC') => { + setSortObject({ field, order }); + setPage(1); + }, + [setPage, setSortObject] + ); + + // selection logic + const { + selectedIds, + onSelect, + onToggleItem, + onUnselectItems, + } = useSelectionState(); + + // filter logic + const filterRef = useRef(filter); + const [displayedFilters, setDisplayedFilters] = useSafeSetState<{ + [key: string]: boolean; + }>({}); + const [filterValues, setFilterValues] = useSafeSetState<{ + [key: string]: any; + }>(filter); + const hideFilter = useCallback( + (filterName: string) => { + setDisplayedFilters(previousState => { + const { [filterName]: _, ...newState } = previousState; + return newState; + }); + setFilterValues(previousState => { + const { [filterName]: _, ...newState } = previousState; + return newState; + }); + }, + [setDisplayedFilters, setFilterValues] + ); + const showFilter = useCallback( + (filterName: string, defaultValue: any) => { + setDisplayedFilters(previousState => ({ + ...previousState, + [filterName]: true, + })); + setFilterValues(previousState => ({ + ...previousState, + [filterName]: defaultValue, + })); + }, + [setDisplayedFilters, setFilterValues] + ); + const setFilters = useCallback( + (filters, displayedFilters) => { + setFilterValues(removeEmpty(filters)); + setDisplayedFilters(displayedFilters); + setPage(1); + }, + [setDisplayedFilters, setFilterValues, setPage] + ); + // handle filter prop change + useEffect(() => { + if (!isEqual(filter, filterRef.current)) { + filterRef.current = filter; + setFilterValues(filter); + } + }); + + // We do all the data processing (filtering, sorting, paginating) client-side + useEffect(() => { + if (!loaded) return; + // 1. filter + let tempData = initialData.filter(record => + Object.entries(filterValues).every(([filterName, filterValue]) => { + const recordValue = get(record, filterName); + const result = Array.isArray(recordValue) + ? Array.isArray(filterValue) + ? recordValue.some(item => filterValue.includes(item)) + : recordValue.includes(filterValue) + : Array.isArray(filterValue) + ? filterValue.includes(recordValue) + : filterValue == recordValue; // eslint-disable-line eqeqeq + return result; + }) + ); + // 2. sort + if (sort.field) { + tempData = tempData.sort((a, b) => { + if (get(a, sort.field) > get(b, sort.field)) { + return sort.order === 'ASC' ? 1 : -1; + } + if (get(a, sort.field) < get(b, sort.field)) { + return sort.order === 'ASC' ? -1 : 1; + } + return 0; + }); + } + // 3. paginate + tempData = tempData.slice((page - 1) * perPage, page * perPage); + const finalData = indexById(tempData); + const finalIds = tempData + .filter(data => typeof data !== 'undefined') + .map(data => data.id); + + setFinalItems({ + data: finalData, + ids: finalIds, + }); + }, [ + initialData, + filterValues, + loaded, + page, + perPage, + setFinalItems, + sort.field, + sort.order, + ]); + + useEffect(() => { + if (loaded !== loadedState) { + setLoadedState(loaded); + } + }, [loaded, loadedState, setLoadedState]); + + useEffect(() => { + if (loading !== loadingState) { + setLoadingState(loading); + } + }, [loading, loadingState, setLoadingState]); + + return { + currentSort: props.currentSort || sort, + data: data || finalItems.data, + error, + displayedFilters: props.displayedFilters || displayedFilters, + filterValues: props.filterValues || filterValues, + hideFilter: props.hideFilter || hideFilter, + ids: ids || finalItems.ids, + loaded: loadedState, + loading: loadingState, + onSelect: props.onSelect || onSelect, + onToggleItem: props.onToggleItem || onToggleItem, + onUnselectItems: props.onUnselectItems || onUnselectItems, + page: props.page || page, + perPage: props.perPage || perPage, + selectedIds: props.selectedIds || selectedIds, + setFilters: props.setFilters || setFilters, + setPage: props.setPage || setPage, + setPerPage: props.setPerPage || setPerPage, + setSort: props.setSort || setSort, + showFilter: props.showFilter || showFilter, + total: total || finalItems.ids.length, + }; +}; + +export interface UseListOptions + extends Partial< + Omit + > { + error?: any; + filter?: any; + initialPage?: number; + initialPerPage?: number; + initialSort?: SortPayload; + initialData: Record[]; + initialIds: Identifier[]; + loaded: boolean; + loading: boolean; +} + +export type UseListValue = Omit< + ListControllerProps, + 'resource' | 'basePath' | 'refetch' +>; + +const defaultFilter = {}; +const defaultSort = { field: null, order: null }; diff --git a/packages/ra-core/src/util/hooks.ts b/packages/ra-core/src/util/hooks.ts index a27cc268595..83c717b7878 100644 --- a/packages/ra-core/src/util/hooks.ts +++ b/packages/ra-core/src/util/hooks.ts @@ -5,7 +5,7 @@ import isEqual from 'lodash/isEqual'; // thanks Kent C Dodds for the following helpers export function useSafeSetState( - initialState?: T + initialState?: T | (() => T) ): [T, React.Dispatch>] { const [state, setState] = useState(initialState); diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index d84d8f97ce9..47c269aa3b8 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -194,7 +194,8 @@ const useStyles = makeStyles( export interface ListViewProps extends Omit, - ListControllerProps { + // Partial because we now get those props via context + Partial { children: ReactElement; } From 90cd17529bbfb7a450a1400dd8b148f6df2ad72d Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 2 Jun 2021 11:27:00 +0200 Subject: [PATCH 2/4] Add comments --- packages/ra-core/src/controller/useList.ts | 42 +++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/ra-core/src/controller/useList.ts b/packages/ra-core/src/controller/useList.ts index ee1fecb319a..2583e391d0d 100644 --- a/packages/ra-core/src/controller/useList.ts +++ b/packages/ra-core/src/controller/useList.ts @@ -17,16 +17,34 @@ import { ListControllerProps } from '.'; * { id: 2, name: 'Sylvester' }, * { id: 3, name: 'Jean-Claude' }, * ] - * const { ids, data, error, loaded, loading } = useList({ - * ids: providedIds, - * data: providedData, - * basePath: '/resource'; - * resource: 'resource'; - * }); * - * @param {Object} props - * @param {string} props.basePath basepath to current resource - * @param {string} props.resource The current resource name + * const MyComponent = () => { + * const listContext = useList({ + * ids: providedIds, + * data: providedData, + * basePath: '/resource'; + * resource: 'resource'; + * }); + * return ( + * + * + * + * + * + * + * ); + * }; + * + * @param {UseListOptions} props Also optionally accepts all the ListController props + * @param {Record[]} props.data An array of records + * @param {Identifier[]} props.ids An array of the record identifiers + * @param {Boolean} props.loaded: A boolean indicating whether the data has been loaded at least once + * @param {Boolean} props.loading: A boolean indicating whether the data is being loaded + * @param {Error | String} props.error: Optional. The error if any occured while loading the data + * @param {Object} props.filter: Optional. An object containing the filters applied on the data + * @param {Number} props.initialPage: Optional. The initial page index + * @param {Number} props.initialPerPage: Optional. The initial page size + * @param {SortPayload} props.initialSort: Optional. The initial sort (field and order) */ export const useList = (props: UseListOptions): UseListValue => { const { @@ -131,6 +149,11 @@ export const useList = (props: UseListOptions): UseListValue => { // We do all the data processing (filtering, sorting, paginating) client-side useEffect(() => { if (!loaded) return; + // Assume that if setFilters is provided then so are methods for pagination and sorting + if (props.setFilters) { + return; + } + // 1. filter let tempData = initialData.filter(record => Object.entries(filterValues).every(([filterName, filterValue]) => { @@ -174,6 +197,7 @@ export const useList = (props: UseListOptions): UseListValue => { loaded, page, perPage, + props.setFilters, setFinalItems, sort.field, sort.order, From 67e1e69fcc0b5c9453daea39d567af1d815835a8 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 2 Jun 2021 11:27:26 +0200 Subject: [PATCH 3/4] export useList --- packages/ra-core/src/controller/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ra-core/src/controller/index.ts b/packages/ra-core/src/controller/index.ts index 56432180237..d2a8f71f597 100644 --- a/packages/ra-core/src/controller/index.ts +++ b/packages/ra-core/src/controller/index.ts @@ -65,3 +65,4 @@ export * from './button'; export * from './details'; export * from './RecordContext'; export * from './saveModifiers'; +export * from './useList'; From 3623e9c20ad1701ef176bf524413fce497c58a70 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 2 Jun 2021 14:28:12 +0200 Subject: [PATCH 4/4] Fix filters handling --- packages/ra-core/src/controller/useList.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/src/controller/useList.ts b/packages/ra-core/src/controller/useList.ts index 2583e391d0d..b72db06bd24 100644 --- a/packages/ra-core/src/controller/useList.ts +++ b/packages/ra-core/src/controller/useList.ts @@ -123,17 +123,21 @@ export const useList = (props: UseListOptions): UseListValue => { ...previousState, [filterName]: true, })); - setFilterValues(previousState => ({ - ...previousState, - [filterName]: defaultValue, - })); + setFilterValues(previousState => + removeEmpty({ + ...previousState, + [filterName]: defaultValue, + }) + ); }, [setDisplayedFilters, setFilterValues] ); const setFilters = useCallback( (filters, displayedFilters) => { setFilterValues(removeEmpty(filters)); - setDisplayedFilters(displayedFilters); + if (displayedFilters) { + setDisplayedFilters(displayedFilters); + } setPage(1); }, [setDisplayedFilters, setFilterValues, setPage]