From aef275f956e9f3b30979018bf50d3a27df8566d1 Mon Sep 17 00:00:00 2001 From: djhi <1122076+djhi@users.noreply.github.com> Date: Mon, 8 Feb 2021 18:09:11 +0100 Subject: [PATCH 01/16] Add ListContext to ReferenceArrayInput --- .../field/useReferenceArrayFieldController.ts | 9 +- .../input/ReferenceArrayInputController.tsx | 88 +++++------- .../input/useReferenceArrayInputController.ts | 135 ++++++++++++++++-- packages/ra-core/src/util/index.ts | 1 + packages/ra-core/src/util/indexById.ts | 9 ++ .../src/input/ReferenceArrayInput.tsx | 104 ++++++++------ 6 files changed, 227 insertions(+), 119 deletions(-) create mode 100644 packages/ra-core/src/util/indexById.ts diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts index 7b2bf4555f4..25441498935 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts @@ -11,6 +11,7 @@ import usePaginationState from '../usePaginationState'; import useSelectionState from '../useSelectionState'; import useSortState from '../useSortState'; import { useResourceContext } from '../../core'; +import { indexById } from '../../util/indexById'; interface Option { basePath: string; @@ -239,12 +240,4 @@ const useReferenceArrayFieldController = ( }; }; -const indexById = (records: Record[] = []): RecordMap => - records - .filter(r => typeof r !== 'undefined') - .reduce((prev, current) => { - prev[current.id] = current; - return prev; - }, {}); - export default useReferenceArrayFieldController; diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx index c6fb8564f15..c30ff7b5f82 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx @@ -1,41 +1,9 @@ -import { - ComponentType, - FunctionComponent, - ReactElement, - useCallback, -} from 'react'; +import { ComponentType, ReactElement, useCallback } from 'react'; import debounce from 'lodash/debounce'; import { Record, SortPayload, PaginationPayload } from '../../types'; import useReferenceArrayInputController from './useReferenceArrayInputController'; -interface ChildrenFuncParams { - choices: Record[]; - error?: string; - loaded: boolean; - loading: boolean; - setFilter: (filter: any) => void; - setPagination: (pagination: PaginationPayload) => void; - setSort: (sort: SortPayload) => void; - warning?: string; -} - -interface Props { - allowEmpty?: boolean; - basePath: string; - children: (params: ChildrenFuncParams) => ReactElement; - filter?: object; - filterToQuery?: (filter: {}) => any; - input?: any; - meta?: object; - perPage?: number; - record?: Record; - reference: string; - resource: string; - sort?: SortPayload; - source: string; -} - /** * An Input component for fields containing a list of references to another resource. * Useful for 'hasMany' relationship. @@ -114,7 +82,7 @@ interface Props { * * */ -const ReferenceArrayInputController: FunctionComponent = ({ +const ReferenceArrayInputController = ({ basePath, children, filter = {}, @@ -125,17 +93,8 @@ const ReferenceArrayInputController: FunctionComponent = ({ resource, sort = { field: 'id', order: 'DESC' }, source, -}) => { - const { - choices, - error, - loaded, - loading, - setFilter, - setPagination, - setSort, - warning, - } = useReferenceArrayInputController({ +}: ReferenceArrayInputControllerProps) => { + const { setFilter, ...controllerProps } = useReferenceArrayInputController({ basePath, filter, filterToQuery, @@ -153,15 +112,38 @@ const ReferenceArrayInputController: FunctionComponent = ({ ]); return children({ - choices, - error, - loaded, - loading, + ...controllerProps, setFilter: debouncedSetFilter, - setPagination, - setSort, - warning, }); }; -export default ReferenceArrayInputController as ComponentType; +interface ChildrenFuncParams { + choices: Record[]; + error?: string; + loaded: boolean; + loading: boolean; + setFilter: (filter: any) => void; + setPagination: (pagination: PaginationPayload) => void; + setSort: (sort: SortPayload) => void; + warning?: string; +} + +interface ReferenceArrayInputControllerProps { + allowEmpty?: boolean; + basePath: string; + children: (params: ChildrenFuncParams) => ReactElement; + filter?: object; + filterToQuery?: (filter: {}) => any; + input?: any; + meta?: object; + perPage?: number; + record?: Record; + reference: string; + resource: string; + sort?: SortPayload; + source: string; +} + +export default ReferenceArrayInputController as ComponentType< + ReferenceArrayInputControllerProps +>; diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 026679fc300..eba2da5580a 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useRef } from 'react'; +import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; import difference from 'lodash/difference'; @@ -14,6 +14,9 @@ import useGetMatching from '../../dataProvider/useGetMatching'; import { useTranslate } from '../../i18n'; import { getStatusForArrayInput as getDataStatus } from './referenceDataStatus'; import { useResourceContext } from '../../core'; +import { usePaginationState, useSelectionState, useSortState } from '..'; +import { ListControllerProps } from '../useListController'; +import { indexById, removeEmpty, useSafeSetState } from '../../util'; /** * Prepare data for the ReferenceArrayInput components @@ -41,13 +44,15 @@ import { useResourceContext } from '../../core'; */ const useReferenceArrayInputController = ( props: Option -): ReferenceArrayInputProps => { +): ReferenceArrayInputProps & Omit => { const { + basePath, filter: defaultFilter, filterToQuery = defaultFilterToQuery, input, - perPage = 25, - sort: defaultSort = { field: 'id', order: 'DESC' }, + page: initialPage = 1, + perPage: initialPerPage = 25, + sort: initialSort = { field: 'id', order: 'DESC' }, options, reference, source, @@ -105,16 +110,43 @@ const useReferenceArrayInputController = ( setIdsToGetFromStore, ]); - const [pagination, setPagination] = useState({ page: 1, perPage }); - const [sort, setSort] = useState(defaultSort); - const [filter, setFilter] = useState(''); + // pagination logic + const { + page, + setPage, + perPage, + setPerPage, + pagination, + setPagination, + } = usePaginationState({ + page: initialPage, + perPage: initialPerPage, + }); + + // selection logic + const { + selectedIds, + onSelect, + onToggleItem, + onUnselectItems, + } = useSelectionState(); + + // sort logic + const { sort, setSort } = useSortState(initialSort); + const setSortForList = useCallback( + (field: string, order: string = 'ASC') => { + setSort({ field, order }); + setPage(1); + }, + [setPage, setSort] + ); // Ensure sort can be updated through props too, not just by using the setSort function useEffect(() => { - if (!isEqual(defaultSort, sort)) { - setSort(defaultSort); + if (!isEqual(initialSort, sort)) { + setSort(initialSort); } - }, [setSort, defaultSort, sort]); + }, [setSort, initialSort, sort]); // Ensure pagination can be updated through props too, not just by using the setPagination function useEffect(() => { @@ -127,13 +159,65 @@ const useReferenceArrayInputController = ( } }, [setPagination, perPage, pagination]); + // filter logic + const [queryFilter, setFilter] = useState(''); + const filterRef = useRef(defaultFilter); + const [displayedFilters, setDisplayedFilters] = useSafeSetState<{ + [key: string]: boolean; + }>({}); + const [filterValues, setFilterValues] = useSafeSetState<{ + [key: string]: any; + }>(defaultFilter); + 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(defaultFilter, filterRef.current)) { + filterRef.current = defaultFilter; + setFilterValues(defaultFilter); + } + }); + // Merge the user filters with the default ones const finalFilter = useMemo( () => ({ ...defaultFilter, - ...filterToQuery(filter), + ...filterToQuery(queryFilter), }), - [defaultFilter, filter, filterToQuery] + [queryFilter, defaultFilter, filterToQuery] ); const { data: referenceRecordsFetched, loaded } = useGetMany( @@ -148,7 +232,7 @@ const useReferenceArrayInputController = ( // filter out not found references - happens when the dataProvider doesn't guarantee referential integrity const finalReferenceRecords = referenceRecords.filter(Boolean); - const { data: matchingReferences } = useGetMatching( + const { data: matchingReferences, total } = useGetMatching( reference, pagination, sort, @@ -175,14 +259,37 @@ const useReferenceArrayInputController = ( }); return { + basePath: basePath.replace(resource, reference), choices: dataStatus.choices, + currentSort: sort, + data: indexById(dataStatus.choices), + displayedFilters, error: dataStatus.error, + filterValues, + hasCreate: false, + hideFilter, + ids: dataStatus.choices + .filter(data => typeof data !== 'undefined') + .map(data => data.id), loaded, loading: dataStatus.waiting, + onSelect, + onToggleItem, + onUnselectItems, + page, + perPage, + resource, + selectedIds, setFilter, + setFilters, + setPage, setPagination, + setPerPage, setSort, + setSortForList, + showFilter, warning: dataStatus.warning, + total, }; }; @@ -219,6 +326,7 @@ interface ReferenceArrayInputProps { setFilter: (filter: any) => void; setPagination: (pagination: PaginationPayload) => void; setSort: (sort: SortPayload) => void; + setSortForList: (sort: string, order?: string) => void; } interface Option { @@ -227,6 +335,7 @@ interface Option { filterToQuery?: (filter: any) => any; input: FieldInputProps; options?: any; + page?: number; perPage?: number; record?: Record; reference: string; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 1f4fae76dca..21d2d79f8e3 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -11,6 +11,7 @@ import resolveRedirectTo from './resolveRedirectTo'; import warning from './warning'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import { useSafeSetState, useTimeout } from './hooks'; +export * from './indexById'; export { escapePath, diff --git a/packages/ra-core/src/util/indexById.ts b/packages/ra-core/src/util/indexById.ts new file mode 100644 index 00000000000..bae5a1b5c8a --- /dev/null +++ b/packages/ra-core/src/util/indexById.ts @@ -0,0 +1,9 @@ +import { Record, RecordMap } from '../types'; + +export const indexById = (records: Record[] = []): RecordMap => + records + .filter(r => typeof r !== 'undefined') + .reduce((prev, current) => { + prev[current.id] = current; + return prev; + }, {}); diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx index ba18b763c61..311a0d34e5f 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FC, ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import PropTypes from 'prop-types'; import { getFieldLabelTranslationArgs, @@ -11,6 +11,7 @@ import { PaginationPayload, Translate, ResourceContextProvider, + ListContextProvider, } from 'ra-core'; import sanitizeInputRestProps from './sanitizeInputRestProps'; @@ -107,7 +108,7 @@ export interface ReferenceArrayInputProps extends InputProps { * * */ -const ReferenceArrayInput: FC = ({ +const ReferenceArrayInput = ({ children, id: idOverride, onBlur, @@ -117,7 +118,7 @@ const ReferenceArrayInput: FC = ({ parse, format, ...props -}) => { +}: ReferenceArrayInputProps) => { if (React.Children.count(children) !== 1) { throw new Error( ' only accepts a single child (like )' @@ -136,24 +137,41 @@ const ReferenceArrayInput: FC = ({ ...props, }); - const controllerProps = useReferenceArrayInputController({ + const { + setSort, + setSortForList, + ...controllerProps + } = useReferenceArrayInputController({ ...props, input, }); + const listContext = useMemo( + () => ({ + ...controllerProps, + setSort: setSortForList, + }), + [controllerProps, setSortForList] + ); + const translate = useTranslate(); return ( - + + + + + ); }; @@ -256,37 +274,33 @@ export const ReferenceArrayInputView = ({ return ; } - return ( - - {React.cloneElement(children, { - allowEmpty, - basePath, - choices, - className, - error, - input, - isRequired, - label: translatedLabel, - loaded, - loading, - meta: { - ...meta, - helperText: warning || false, - }, - onChange, - options, - resource, - setFilter, - setPagination, - setSort, - source, - translateChoice: false, - limitChoicesToValue: true, - ...sanitizeRestProps(rest), - ...children.props, - })} - - ); + return React.cloneElement(children, { + allowEmpty, + basePath, + choices, + className, + error, + input, + isRequired, + label: translatedLabel, + loaded, + loading, + meta: { + ...meta, + helperText: warning || false, + }, + onChange, + options, + resource, + setFilter, + setPagination, + setSort, + source, + translateChoice: false, + limitChoicesToValue: true, + ...sanitizeRestProps(rest), + ...children.props, + }); }; ReferenceArrayInputView.propTypes = { From f9ad42b04a3f801957097ecb4581f25f7bf1a3ae Mon Sep 17 00:00:00 2001 From: djhi <1122076+djhi@users.noreply.github.com> Date: Mon, 8 Feb 2021 18:33:12 +0100 Subject: [PATCH 02/16] Fix sort --- .../controller/input/useReferenceArrayInputController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index eba2da5580a..a10918687f2 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -132,9 +132,11 @@ const useReferenceArrayInputController = ( } = useSelectionState(); // sort logic + const sortRef = useRef(initialSort); const { sort, setSort } = useSortState(initialSort); const setSortForList = useCallback( (field: string, order: string = 'ASC') => { + console.log({ field, order }); setSort({ field, order }); setPage(1); }, @@ -143,10 +145,10 @@ const useReferenceArrayInputController = ( // Ensure sort can be updated through props too, not just by using the setSort function useEffect(() => { - if (!isEqual(initialSort, sort)) { + if (!isEqual(initialSort, sortRef.current)) { setSort(initialSort); } - }, [setSort, initialSort, sort]); + }, [setSort, initialSort]); // Ensure pagination can be updated through props too, not just by using the setPagination function useEffect(() => { From e40913b4355234d3a693cbcc05436f6895b3b075 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 12:10:22 +0100 Subject: [PATCH 03/16] Fix sorting --- .../input/useReferenceArrayInputController.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index a10918687f2..398360b53cf 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -1,6 +1,8 @@ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; +import get from 'lodash/get'; +import sortBy from 'lodash/sortBy'; import difference from 'lodash/difference'; import { PaginationPayload, @@ -17,6 +19,7 @@ import { useResourceContext } from '../../core'; import { usePaginationState, useSelectionState, useSortState } from '..'; import { ListControllerProps } from '../useListController'; import { indexById, removeEmpty, useSafeSetState } from '../../util'; +import { SORT_DESC } from '../../reducer/admin/resource/list/queryReducer'; /** * Prepare data for the ReferenceArrayInput components @@ -136,7 +139,6 @@ const useReferenceArrayInputController = ( const { sort, setSort } = useSortState(initialSort); const setSortForList = useCallback( (field: string, order: string = 'ASC') => { - console.log({ field, order }); setSort({ field, order }); setPage(1); }, @@ -260,6 +262,8 @@ const useReferenceArrayInputController = ( translate, }); + let sortedChoices = sortChoices(dataStatus.choices, sort); + return { basePath: basePath.replace(resource, reference), choices: dataStatus.choices, @@ -270,9 +274,7 @@ const useReferenceArrayInputController = ( filterValues, hasCreate: false, hideFilter, - ids: dataStatus.choices - .filter(data => typeof data !== 'undefined') - .map(data => data.id), + ids: sortedChoices.map(choice => choice.id), loaded, loading: dataStatus.waiting, onSelect, @@ -295,6 +297,19 @@ const useReferenceArrayInputController = ( }; }; +const sortChoices = (choices: Record[], sort: SortPayload) => { + let sortedChoices = sortBy( + choices.filter(choice => typeof choice !== 'undefined'), + choice => get(choice, sort.field) + ); + + if (sort.order === SORT_DESC) { + return sortedChoices.reverse(); + } + + return sortedChoices; +}; + // concatenate and deduplicate two lists of records const mergeReferences = (ref1: Record[], ref2: Record[]): Record[] => { const res = [...ref1]; From 8300510bb670b0b6d0fb61152e86f612d690c4a1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 15:51:31 +0100 Subject: [PATCH 04/16] Ensure pagination works --- .../input/useReferenceArrayInputController.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 398360b53cf..973cadf3d9b 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -153,15 +153,12 @@ const useReferenceArrayInputController = ( }, [setSort, initialSort]); // Ensure pagination can be updated through props too, not just by using the setPagination function + const paginationRef = useRef({ initialPage, initialPerPage }); useEffect(() => { - const newPagination = { - page: 1, - perPage, - }; - if (!isEqual(newPagination, pagination)) { - setPagination(newPagination); + if (!isEqual({ initialPage, initialPerPage }, paginationRef.current)) { + setPagination({ page: initialPage, perPage: initialPerPage }); } - }, [setPagination, perPage, pagination]); + }, [setPagination, initialPage, initialPerPage]); // filter logic const [queryFilter, setFilter] = useState(''); @@ -236,7 +233,11 @@ const useReferenceArrayInputController = ( // filter out not found references - happens when the dataProvider doesn't guarantee referential integrity const finalReferenceRecords = referenceRecords.filter(Boolean); - const { data: matchingReferences, total } = useGetMatching( + const { + data: matchingReferences, + ids: matchingReferencesIds, + total, + } = useGetMatching( reference, pagination, sort, @@ -262,19 +263,20 @@ const useReferenceArrayInputController = ( translate, }); - let sortedChoices = sortChoices(dataStatus.choices, sort); - return { basePath: basePath.replace(resource, reference), choices: dataStatus.choices, currentSort: sort, - data: indexById(dataStatus.choices), + data: + matchingReferences && matchingReferences.length > 0 + ? indexById(matchingReferences) + : {}, displayedFilters, error: dataStatus.error, filterValues, hasCreate: false, hideFilter, - ids: sortedChoices.map(choice => choice.id), + ids: matchingReferencesIds || [], loaded, loading: dataStatus.waiting, onSelect, From cf99a744ccaf88a8cfdbbc454d4bf150de7f1ae1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 15:51:58 +0100 Subject: [PATCH 05/16] Enable input change through selection --- .../input/useReferenceArrayInputController.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 973cadf3d9b..8757193bf09 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -11,7 +11,7 @@ import { ReduxState, } from '../../types'; import { useGetMany } from '../../dataProvider'; -import { FieldInputProps } from 'react-final-form'; +import { FieldInputProps, useForm } from 'react-final-form'; import useGetMatching from '../../dataProvider/useGetMatching'; import { useTranslate } from '../../i18n'; import { getStatusForArrayInput as getDataStatus } from './referenceDataStatus'; @@ -132,7 +132,14 @@ const useReferenceArrayInputController = ( onSelect, onToggleItem, onUnselectItems, - } = useSelectionState(); + } = useSelectionState(input.value); + + const form = useForm(); + useEffect(() => { + if (!isEqual(input.value, selectedIds)) { + form.change(input.name, selectedIds); + } + }, [input.name, input.value, selectedIds, form]); // sort logic const sortRef = useRef(initialSort); From ea2ee51883db000a6efc9dec72b23e7eab491150 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 16:03:06 +0100 Subject: [PATCH 06/16] Add comments --- .../src/controller/input/useReferenceArrayInputController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 8757193bf09..940da464013 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -274,6 +274,8 @@ const useReferenceArrayInputController = ( basePath: basePath.replace(resource, reference), choices: dataStatus.choices, currentSort: sort, + // For the ListContext, we don't want to always display the selected items first. + // Indeed it wouldn't work well regarding sorting and pagination data: matchingReferences && matchingReferences.length > 0 ? indexById(matchingReferences) @@ -283,6 +285,8 @@ const useReferenceArrayInputController = ( filterValues, hasCreate: false, hideFilter, + // For the ListContext, we don't want to always display the selected items first. + // Indeed it wouldn't work well regarding sorting and pagination ids: matchingReferencesIds || [], loaded, loading: dataStatus.waiting, From b63b683a3a2338a794803144abed3150aee8a3b3 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 16:28:15 +0100 Subject: [PATCH 07/16] Fix selection and tests --- .../ReferenceArrayInputController.spec.tsx | 407 ++++++++++++------ .../input/useReferenceArrayInputController.ts | 47 +- 2 files changed, 296 insertions(+), 158 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx index 2d4b6fb3623..bd914af6986 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import expect from 'expect'; import { waitFor, fireEvent } from '@testing-library/react'; +import { Form } from 'react-final-form'; import ReferenceArrayInputController from './ReferenceArrayInputController'; import { renderWithRedux } from 'ra-test'; import { CRUD_GET_MATCHING, CRUD_GET_MANY } from '../../../lib'; @@ -20,12 +21,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , +
( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } } } } ); @@ -37,12 +43,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } } } } ); expect(queryByText('true')).not.toBeNull(); @@ -53,12 +64,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -82,9 +98,14 @@ describe('', () => { const children = jest.fn(({ error }) =>
{error}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { references: { @@ -102,12 +123,17 @@ describe('', () => { it('should set error in case of references fetch error and there are no data found for the references already selected', () => { const children = jest.fn(({ error }) =>
{error}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } }, @@ -125,12 +151,17 @@ describe('', () => { it('should not display an error in case of references fetch error but data from at least one selected reference was found', () => { const children = jest.fn(({ error }) =>
{error}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -159,12 +190,17 @@ describe('', () => { it('should set warning if references fetch fails but selected references are not empty', () => { const children = jest.fn(({ warning }) =>
{warning}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -193,12 +229,17 @@ describe('', () => { it('should set warning if references were found but selected references are not complete', () => { const children = jest.fn(({ warning }) =>
{warning}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -227,12 +268,17 @@ describe('', () => { it('should set warning if references were found but selected references are empty', () => { const children = jest.fn(({ warning }) =>
{warning}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {}, 6: {} } } }, @@ -250,12 +296,17 @@ describe('', () => { it('should not set warning if all references were found', () => { const children = jest.fn(({ warning }) =>
{warning}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -288,9 +339,14 @@ describe('', () => { const children = jest.fn(() =>
); await new Promise(resolve => setTimeout(resolve, 100)); // empty the query deduplication in useQueryWithStore const { dispatch } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } } } } ); expect(dispatch.mock.calls[0][0]).toEqual({ @@ -317,14 +373,19 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch } = renderWithRedux( - - {children} - + ( + + {children} + + )} + /> ); expect(dispatch.mock.calls[0][0]).toEqual({ type: CRUD_GET_MATCHING, @@ -352,9 +413,14 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - - {children} - + ( + + {children} + + )} + /> ); fireEvent.click(getByLabelText('Filter')); @@ -387,12 +453,17 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - ({ foo: searchText })} - > - {children} - + ( + ({ foo: searchText })} + > + {children} + + )} + /> ); fireEvent.click(getByLabelText('Filter')); @@ -423,12 +494,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {}, 6: {} } } } } } ); await waitFor(() => { @@ -448,12 +524,17 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {} } } } } } ); @@ -477,23 +558,33 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {} } } } } } ); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -510,14 +601,19 @@ describe('', () => { }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -534,15 +630,20 @@ describe('', () => { }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -563,12 +664,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -593,12 +699,17 @@ describe('', () => { }); }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -616,12 +727,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {}, 6: {} } } } } } ); await waitFor(() => { @@ -634,12 +750,17 @@ describe('', () => { }); }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 940da464013..15185401d8b 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -9,6 +9,7 @@ import { Record, SortPayload, ReduxState, + Identifier, } from '../../types'; import { useGetMany } from '../../dataProvider'; import { FieldInputProps, useForm } from 'react-final-form'; @@ -16,7 +17,7 @@ import useGetMatching from '../../dataProvider/useGetMatching'; import { useTranslate } from '../../i18n'; import { getStatusForArrayInput as getDataStatus } from './referenceDataStatus'; import { useResourceContext } from '../../core'; -import { usePaginationState, useSelectionState, useSortState } from '..'; +import { usePaginationState, useSortState } from '..'; import { ListControllerProps } from '../useListController'; import { indexById, removeEmpty, useSafeSetState } from '../../util'; import { SORT_DESC } from '../../reducer/admin/resource/list/queryReducer'; @@ -126,20 +127,36 @@ const useReferenceArrayInputController = ( perPage: initialPerPage, }); - // selection logic - const { - selectedIds, - onSelect, - onToggleItem, - onUnselectItems, - } = useSelectionState(input.value); - const form = useForm(); - useEffect(() => { - if (!isEqual(input.value, selectedIds)) { - form.change(input.name, selectedIds); - } - }, [input.name, input.value, selectedIds, form]); + const onSelect = useCallback( + (newIds: Identifier[]) => { + const newValue = new Set(input.value); + newIds.forEach(newId => { + newValue.add(newId); + }); + + form.change(input.name, Array.from(newValue)); + }, + [form, input.name, input.value] + ); + + const onUnselectItems = useCallback(() => { + form.change(input.name, []); + }, [form, input.name]); + + const onToggleItem = useCallback( + (id: Identifier) => { + if (input.value.contains(id)) { + form.change( + input.name, + input.value.filter(selectedId => selectedId !== id) + ); + } else { + form.change(input.name, [...input.value, id]); + } + }, + [form, input.name, input.value] + ); // sort logic const sortRef = useRef(initialSort); @@ -296,7 +313,7 @@ const useReferenceArrayInputController = ( page, perPage, resource, - selectedIds, + selectedIds: input.value, setFilter, setFilters, setPage, From f082036e2a7b6cec431a90822f48a8bccddcf21f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 9 Feb 2021 16:54:18 +0100 Subject: [PATCH 08/16] Fix selection --- .../src/controller/input/useReferenceArrayInputController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 15185401d8b..d99e1da86dc 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -146,7 +146,7 @@ const useReferenceArrayInputController = ( const onToggleItem = useCallback( (id: Identifier) => { - if (input.value.contains(id)) { + if (input.value.some(selectedId => selectedId === id)) { form.change( input.name, input.value.filter(selectedId => selectedId !== id) From 94fbce413f6352490b7688517032ef0fed06fc24 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:18:27 +0100 Subject: [PATCH 09/16] Cleanup unused code --- .../input/useReferenceArrayInputController.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index d99e1da86dc..04e62a8ca48 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -327,19 +327,6 @@ const useReferenceArrayInputController = ( }; }; -const sortChoices = (choices: Record[], sort: SortPayload) => { - let sortedChoices = sortBy( - choices.filter(choice => typeof choice !== 'undefined'), - choice => get(choice, sort.field) - ); - - if (sort.order === SORT_DESC) { - return sortedChoices.reverse(); - } - - return sortedChoices; -}; - // concatenate and deduplicate two lists of records const mergeReferences = (ref1: Record[], ref2: Record[]): Record[] => { const res = [...ref1]; From 729309ab38087c29042b945fea510cbddefb6156 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:18:47 +0100 Subject: [PATCH 10/16] Export Child Function Interface --- .../controller/input/ReferenceArrayInputController.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx index c30ff7b5f82..2474beb1f1c 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.tsx @@ -3,6 +3,7 @@ import debounce from 'lodash/debounce'; import { Record, SortPayload, PaginationPayload } from '../../types'; import useReferenceArrayInputController from './useReferenceArrayInputController'; +import { ListControllerProps } from '..'; /** * An Input component for fields containing a list of references to another resource. @@ -117,7 +118,8 @@ const ReferenceArrayInputController = ({ }); }; -interface ChildrenFuncParams { +export interface ReferenceArrayInputControllerChildrenFuncParams + extends Omit { choices: Record[]; error?: string; loaded: boolean; @@ -125,13 +127,16 @@ interface ChildrenFuncParams { setFilter: (filter: any) => void; setPagination: (pagination: PaginationPayload) => void; setSort: (sort: SortPayload) => void; + setSortForList: (sort: string, order?: string) => void; warning?: string; } interface ReferenceArrayInputControllerProps { allowEmpty?: boolean; basePath: string; - children: (params: ChildrenFuncParams) => ReactElement; + children: ( + params: ReferenceArrayInputControllerChildrenFuncParams + ) => ReactElement; filter?: object; filterToQuery?: (filter: {}) => any; input?: any; From 1991de73d7b9f8bf8137745595cebf18fe49cbed Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 11 Feb 2021 15:18:55 +0100 Subject: [PATCH 11/16] Add tests --- .../ReferenceArrayInputController.spec.tsx | 115 +++++++++++++++++- .../src/input/ReferenceArrayInput.spec.js | 67 +++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx index bd914af6986..47491d613ef 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx @@ -2,9 +2,12 @@ import * as React from 'react'; import expect from 'expect'; import { waitFor, fireEvent } from '@testing-library/react'; import { Form } from 'react-final-form'; -import ReferenceArrayInputController from './ReferenceArrayInputController'; import { renderWithRedux } from 'ra-test'; +import ReferenceArrayInputController, { + ReferenceArrayInputControllerChildrenFuncParams, +} from './ReferenceArrayInputController'; import { CRUD_GET_MATCHING, CRUD_GET_MANY } from '../../../lib'; +import { SORT_ASC } from '../../reducer/admin/resource/list/queryReducer'; describe('', () => { const defaultProps = { @@ -771,4 +774,114 @@ describe('', () => { ).toEqual(1); }); }); + + it('should props compatible with the ListContext', async () => { + const children = ({ + setPage, + setPerPage, + setSortForList, + }: ReferenceArrayInputControllerChildrenFuncParams): React.ReactElement => { + const handleSetPage = () => { + setPage(2); + }; + const handleSetPerPage = () => { + setPerPage(50); + }; + const handleSetSort = () => { + setSortForList('name', SORT_ASC); + }; + + return ( + <> +