diff --git a/docs/ReferenceArrayInput.md b/docs/ReferenceArrayInput.md index d0bd52fa11a..4c01437949d 100644 --- a/docs/ReferenceArrayInput.md +++ b/docs/ReferenceArrayInput.md @@ -114,7 +114,7 @@ See the [`children`](#children) section for more details. ## `children` -By default, `` renders an [``](./AutocompleteArrayInput.md) to let end users select the reference record. +By default, `` renders an [``](./AutocompleteArrayInput.md) to let end users select the reference record. You can pass a child component to customize the way the reference selector is displayed. diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputBase.spec.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.spec.tsx new file mode 100644 index 00000000000..4cd887b2709 --- /dev/null +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.spec.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { testDataProvider } from 'ra-core'; +import { Basic, WithError } from './ReferenceArrayInputBase.stories'; + +describe('', () => { + afterEach(async () => { + // wait for the getManyAggregate batch to resolve + await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); + }); + + it('should pass down the error if any occurred', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await waitFor(() => { + expect(screen.queryByText('Error: fetch error')).not.toBeNull(); + }); + }); + it('should pass the correct resource down to child component', async () => { + render(); + // Check that the child component receives the correct resource (tags) + await screen.findByText('Selected tags: 1, 3'); + }); + + it('should provide a ChoicesContext with all available choices', async () => { + render(); + await screen.findByText('Total tags: 5'); + }); + + it('should apply default values', async () => { + render(); + // Check that the default values are applied (1, 3) + await screen.findByText('Selected tags: 1, 3'); + }); + + it('should accept meta in queryOptions', async () => { + const getList = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: [], total: 25 }) + ); + const dataProvider = testDataProvider({ getList }); + render(); + await waitFor(() => { + expect(getList).toHaveBeenCalledWith('tags', { + filter: {}, + pagination: { page: 1, perPage: 25 }, + sort: { field: 'id', order: 'DESC' }, + meta: { foo: 'bar' }, + signal: undefined, + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputBase.stories.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.stories.tsx new file mode 100644 index 00000000000..8194fb65fb3 --- /dev/null +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.stories.tsx @@ -0,0 +1,236 @@ +import * as React from 'react'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { CoreAdmin } from '../../core/CoreAdmin'; +import { Resource } from '../../core/Resource'; +import { CreateBase } from '../../controller/create/CreateBase'; +import { testDataProvider } from '../../dataProvider/testDataProvider'; +import { DataProvider } from '../../types'; +import { Form } from '../../form/Form'; +import { useInput } from '../../form/useInput'; +import { InputProps } from '../../form/types'; +import { TestMemoryRouter } from '../../routing/TestMemoryRouter'; +import { + ReferenceArrayInputBase, + ReferenceArrayInputBaseProps, +} from './ReferenceArrayInputBase'; +import { + ChoicesContextValue, + ChoicesProps, + useChoicesContext, +} from '../../form'; +import { useGetRecordRepresentation } from '../..'; + +export default { title: 'ra-core/controller/ReferenceArrayInputBase' }; + +const tags = [ + { id: 0, name: '3D' }, + { id: 1, name: 'Architecture' }, + { id: 2, name: 'Design' }, + { id: 3, name: 'Painting' }, + { id: 4, name: 'Photography' }, +]; + +const defaultDataProvider = testDataProvider({ + getList: () => + // @ts-ignore + Promise.resolve({ + data: tags, + total: tags.length, + }), + // @ts-ignore + getMany: (resource, params) => { + if (process.env.NODE_ENV !== 'test') { + console.log('getMany', resource, params); + } + return Promise.resolve({ + data: params.ids.map(id => tags.find(tag => tag.id === id)), + }); + }, +}); + +const i18nProvider = polyglotI18nProvider(() => englishMessages); + +const CheckboxGroupInput = ( + props: Omit & ChoicesProps +) => { + const choicesContext = useChoicesContext(props); + + return ; +}; + +const CheckboxGroupInputBase = ( + props: Omit & ChoicesProps & ChoicesContextValue +) => { + const { allChoices, isPending, error, resource, source, total } = props; + const input = useInput({ ...props, source }); + const getRecordRepresentation = useGetRecordRepresentation(resource); + + if (isPending) { + return Loading...; + } + + if (error) { + return Error: {error.message}; + } + + return ( +
+ {allChoices.map(choice => ( + + ))} +
+ Selected {resource}: {input.field.value.join(', ')} +
+
+ Total {resource}: {total} +
+
+ ); +}; + +export const Basic = ({ + dataProvider = defaultDataProvider, + meta, + ...props +}: Partial & { + dataProvider?: DataProvider; + meta?: boolean; +}) => ( + + + +

Create Post

+
+ + + +
+ + } + /> +
+
+); + +Basic.args = { + meta: false, +}; + +Basic.argTypes = { + meta: { control: 'boolean' }, +}; + +export const WithRender = ({ + dataProvider = defaultDataProvider, + meta, + ...props +}: Partial & { + dataProvider?: DataProvider; + meta?: boolean; +}) => ( + + + +

Create Post

+
+ ( + + )} + /> + + + } + /> +
+
+); + +WithRender.args = { + meta: false, +}; + +WithRender.argTypes = { + meta: { control: 'boolean' }, +}; + +export const WithError = () => ( + + Promise.reject(new Error('fetch error')), + getMany: () => + Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), + } as unknown as DataProvider + } + i18nProvider={i18nProvider} + > + +

Create Post

+
{}} + defaultValues={{ tag_ids: [1, 3] }} + > + + + +
+ + } + /> +
+
+); diff --git a/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx new file mode 100644 index 00000000000..20772908911 --- /dev/null +++ b/packages/ra-core/src/controller/input/ReferenceArrayInputBase.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { InputProps } from '../../form/types'; +import { + useReferenceArrayInputController, + type UseReferenceArrayInputParams, +} from './useReferenceArrayInputController'; +import { ResourceContextProvider } from '../../core/ResourceContextProvider'; +import { ChoicesContextProvider } from '../../form/choices/ChoicesContextProvider'; +import { RaRecord } from '../../types'; +import { ChoicesContextValue } from '../../form'; + +/** + * An Input component for fields containing a list of references to another resource. + * Useful for 'hasMany' relationship. + * + * @example + * The post object has many tags, so the post resource looks like: + * { + * id: 1234, + * tag_ids: [ "1", "23", "4" ] + * } + * + * ReferenceArrayInputBase component fetches the current resources (using + * `dataProvider.getMany()`) as well as possible resources (using + * `dataProvider.getList()`) in the reference endpoint. It then + * delegates rendering to its child component, to which it makes the possible + * choices available through the ChoicesContext. + * + * Use it with a selector component as child, like `` + * or . + * + * @example + * export const PostEdit = () => ( + * + * + * + * + * + * + * + * ); + * + * By default, restricts the possible values to 25. You can extend this limit + * by setting the `perPage` prop. + * + * @example + * + * + * + * + * By default, orders the possible values by id desc. You can change this order + * by setting the `sort` prop (an object with `field` and `order` properties). + * + * @example + * + * + * + * + * Also, you can filter the query used to populate the possible values. Use the + * `filter` prop for that. + * + * @example + * + * + * + * + * The enclosed component may filter results. ReferenceArrayInputBase create a ChoicesContext which provides + * a `setFilters` function. You can call this function to filter the results. + */ +export const ReferenceArrayInputBase = ( + props: ReferenceArrayInputBaseProps +) => { + const { children, filter = defaultFilter, reference, render, sort } = props; + if (children && React.Children.count(children) !== 1) { + throw new Error( + ' only accepts a single child (like )' + ); + } + + if (!render && !children) { + throw new Error( + " requires either a 'render' prop or 'children' prop" + ); + } + + const controllerProps = useReferenceArrayInputController({ + ...props, + sort, + filter, + }); + + return ( + + + {render ? render(controllerProps) : children} + + + ); +}; + +const defaultFilter = {}; + +export interface ReferenceArrayInputBaseProps + extends InputProps, + UseReferenceArrayInputParams { + children?: React.ReactNode; + render?: (context: ChoicesContextValue) => React.ReactNode; +} diff --git a/packages/ra-core/src/controller/input/index.ts b/packages/ra-core/src/controller/input/index.ts index d183217a3d0..d699b0bf3c6 100644 --- a/packages/ra-core/src/controller/input/index.ts +++ b/packages/ra-core/src/controller/input/index.ts @@ -7,6 +7,7 @@ import { export * from './useReferenceArrayInputController'; export * from './useReferenceInputController'; export * from './ReferenceInputBase'; +export * from './ReferenceArrayInputBase'; export { getStatusForInput, diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx index 19830fb0f19..d50c2ef1d56 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx @@ -2,15 +2,16 @@ import * as React from 'react'; import { DataProvider, Form, + Resource, testDataProvider, TestMemoryRouter, } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import { Admin, Resource } from 'react-admin'; import fakeRestProvider from 'ra-data-fakerest'; import { AdminContext } from '../AdminContext'; +import { AdminUI } from '../AdminUI'; import { Create, Edit } from '../detail'; import { SimpleForm } from '../form'; import { DatagridInput, TextInput } from '../input'; @@ -31,8 +32,8 @@ const tags = [ ]; const dataProvider = testDataProvider({ - // @ts-ignore getList: () => + // @ts-ignore Promise.resolve({ data: tags, total: tags.length, @@ -50,27 +51,29 @@ const i18nProvider = polyglotI18nProvider(() => englishMessages); export const Basic = () => ( - - - ( - - - - - - )} - /> - + + + + ( + + + + + + )} + /> + + ); diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx index ff2ffc811bc..830ee8a8692 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx @@ -1,12 +1,5 @@ import * as React from 'react'; -import { ReactElement } from 'react'; -import { - InputProps, - useReferenceArrayInputController, - ResourceContextProvider, - ChoicesContextProvider, - UseReferenceArrayInputParams, -} from 'ra-core'; +import { ReferenceArrayInputBase, ReferenceArrayInputBaseProps } from 'ra-core'; import { AutocompleteArrayInput } from './AutocompleteArrayInput'; /** @@ -77,40 +70,20 @@ import { AutocompleteArrayInput } from './AutocompleteArrayInput'; * a `setFilters` function. You can call this function to filter the results. */ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { - const { - children = defaultChildren, - reference, - sort, - filter = defaultFilter, - } = props; + const { children = defaultChildren, ...rest } = props; if (React.Children.count(children) !== 1) { throw new Error( - ' only accepts a single child (like )' + ' only accepts a single child (like )' ); } - const controllerProps = useReferenceArrayInputController({ - ...props, - sort, - filter, - }); - return ( - - - {children} - - + {children} ); }; const defaultChildren = ; -const defaultFilter = {}; -export interface ReferenceArrayInputProps - extends InputProps, - UseReferenceArrayInputParams { - children?: ReactElement; +export interface ReferenceArrayInputProps extends ReferenceArrayInputBaseProps { label?: string; - [key: string]: any; }