diff --git a/docs/Inputs.md b/docs/Inputs.md index f90bf7720f9..e9b620929e2 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -1406,7 +1406,7 @@ http://myapi.com/tags?id=[1,23,4] http://myapi.com/tags?page=1&perPage=25 ``` -Once it receives the deduplicated reference resources, this component delegates rendering to a subcomponent, to which it passes the possible choices as the `choices` attribute. +Once it receives the deduplicated reference resources, this component delegates rendering to a subcomponent, by providing the possible choices through the `ReferenceArrayInputContext`. This context value can be accessed with the [`useReferenceArrayInputContext`](#usereferencearrayinputcontext) hook. This means you can use `` with [``](#selectarrayinput), or with the component of your choice, provided it supports the `choices` attribute. @@ -1477,8 +1477,30 @@ You can tweak how this component fetches the possible values using the `perPage` ``` {% endraw %} +In addition to the `ReferenceArrayInputContext`, `` also sets up a `ListContext` providing access to the records from the reference resource in a similar fashion to that of the `` component. This `ListContext` value is accessible with the [`useListContext`](/List.md#uselistcontext) hook. + `` also accepts the [common input props](./Inputs.md#common-input-props). +### `useReferenceArrayInputContext` + +The [``](#referencearrayinput) component take care of fetching the data, and put that data in a context called `ReferenceArrayInputContext` so that it’s available for its descendants. This context also stores filters, pagination, sort state, and provides callbacks to update them. + +Any component decendent of `` can grab information from the `ReferenceArrayInputContext` using the `useReferenceArrayInputContext` hook. Here is what it returns: + +```js +const { + choices, // An array of records matching both the current input value and the filters + error, // A potential error that may have occured while fetching the data + warning, // A potential warning regarding missing references + loaded, // boolean that is false until the data is available + loading, // boolean that is true on mount, and false once the data was fetched + setFilter, // a callback to update the filters, e.g. setFilters({ q: 'query' }) + setPagination, // a callback to change the pagination, e.g. setPagination({ page: 2, perPage: 50 }) + setSort, // a callback to change the sort, e.g. setSort({ field: 'name', order: 'DESC' }) + setSortForList, // a callback to set the sort with the same signature as the one from the ListContext. This is required to avoid breaking backward compatibility and will be removed in v4 +} = useReferenceArrayInputContext(); +``` + ### `` Use `` for foreign-key values, for instance, to edit the `post_id` of a `comment` resource. This component fetches the related record (using `dataProvider.getMany()`) as well as possible choices (using `dataProvider.getList()` in the reference resource), then delegates rendering to a subcomponent, to which it passes the possible choices as the `choices` attribute. 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/ReferenceArrayInputContext.ts b/packages/ra-core/src/controller/input/ReferenceArrayInputContext.ts new file mode 100644 index 00000000000..c9fd7d80f5d --- /dev/null +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputContext.ts @@ -0,0 +1,29 @@ +import { createContext } from 'react'; +import { PaginationPayload, Record, SortPayload } from '../../types'; + +/** + * Context which provides access to the useReferenceArrayInput features. + * + * @example + * const ReferenceArrayInput = ({ children }) => { + * const controllerProps = useReferenceArrayInputController(); + * return ( + * + * {children} + * + * ) + * } + */ +export const ReferenceArrayInputContext = createContext(undefined); + +export interface ReferenceArrayInputContextValue { + choices: Record[]; + error?: any; + warning?: any; + loading: boolean; + loaded: boolean; + setFilter: (filter: any) => void; + setPagination: (pagination: PaginationPayload) => void; + setSort: (sort: SortPayload) => void; + setSortForList: (sort: string, order?: string) => void; +} diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputContextProvider.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputContextProvider.tsx new file mode 100644 index 00000000000..d3229d65ee1 --- /dev/null +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputContextProvider.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { + ReferenceArrayInputContext, + ReferenceArrayInputContextValue, +} from './ReferenceArrayInputContext'; + +/** + * Provider for the context which provides access to the useReferenceArrayInput features. + * + * @example + * const ReferenceArrayInput = ({ children }) => { + * const controllerProps = useReferenceArrayInputController(); + * return ( + * + * {children} + * + * ) + * } + */ +export const ReferenceArrayInputContextProvider = ({ + children, + value, +}: { + children: ReactNode; + value: ReferenceArrayInputContextValue; +}) => ( + + {children} + +); diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx index 2d4b6fb3623..47491d613ef 100644 --- a/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputController.spec.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import expect from 'expect'; import { waitFor, fireEvent } from '@testing-library/react'; -import ReferenceArrayInputController from './ReferenceArrayInputController'; +import { Form } from 'react-final-form'; 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 = { @@ -20,12 +24,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , +
( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } } } } ); @@ -37,12 +46,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: {} } } } } ); expect(queryByText('true')).not.toBeNull(); @@ -53,12 +67,17 @@ describe('', () => {
{loading.toString()}
)); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -82,9 +101,14 @@ describe('', () => { const children = jest.fn(({ error }) =>
{error}
); const { queryByText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { references: { @@ -102,12 +126,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 +154,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 +193,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 +232,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 +271,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 +299,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 +342,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 +376,19 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch } = renderWithRedux( - - {children} - + ( + + {children} + + )} + /> ); expect(dispatch.mock.calls[0][0]).toEqual({ type: CRUD_GET_MATCHING, @@ -352,9 +416,14 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - - {children} - + ( + + {children} + + )} + /> ); fireEvent.click(getByLabelText('Filter')); @@ -387,12 +456,17 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - ({ foo: searchText })} - > - {children} - + ( + ({ foo: searchText })} + > + {children} + + )} + /> ); fireEvent.click(getByLabelText('Filter')); @@ -423,12 +497,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {}, 6: {} } } } } } ); await waitFor(() => { @@ -448,12 +527,17 @@ describe('', () => { )); const { dispatch, getByLabelText } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {} } } } } } ); @@ -477,23 +561,33 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {} } } } } } ); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -510,14 +604,19 @@ describe('', () => { }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -534,15 +633,20 @@ describe('', () => { }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -563,12 +667,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { @@ -593,12 +702,17 @@ describe('', () => { }); }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -616,12 +730,17 @@ describe('', () => { const children = jest.fn(() =>
); const { dispatch, rerender } = renderWithRedux( - - {children} - , + ( + + {children} + + )} + />, { admin: { resources: { tags: { data: { 5: {}, 6: {} } } } } } ); await waitFor(() => { @@ -634,12 +753,17 @@ describe('', () => { }); }); rerender( - - {children} - + ( + + {children} + + )} + /> ); await waitFor(() => { @@ -650,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 ( + <> +