From 33b915862536497b8e66e3a24bb8be56a3ddd693 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 23 Jul 2022 17:23:04 +0200 Subject: [PATCH 01/23] add recordRepresenation to Resource --- packages/ra-core/src/core/Resource.tsx | 2 + packages/ra-core/src/core/index.ts | 1 + .../core/useGetRecordRepresentation.spec.tsx | 87 +++++++++++++++++++ .../src/core/useGetRecordRepresentation.ts | 36 ++++++++ .../ra-core/src/core/useResourceDefinition.ts | 18 +++- .../src/core/useResourceDefinitions.ts | 1 + packages/ra-core/src/types.ts | 7 ++ 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx create mode 100644 packages/ra-core/src/core/useGetRecordRepresentation.ts diff --git a/packages/ra-core/src/core/Resource.tsx b/packages/ra-core/src/core/Resource.tsx index 58d257941f1..be610e4946c 100644 --- a/packages/ra-core/src/core/Resource.tsx +++ b/packages/ra-core/src/core/Resource.tsx @@ -50,6 +50,7 @@ Resource.registerResource = ({ name, options, show, + recordRepresentation, }: ResourceProps) => ({ name, options, @@ -58,4 +59,5 @@ Resource.registerResource = ({ hasEdit: !!edit, hasShow: !!show, icon, + recordRepresentation, }); diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts index 8a9747ef01b..ac7a29add76 100644 --- a/packages/ra-core/src/core/index.ts +++ b/packages/ra-core/src/core/index.ts @@ -12,3 +12,4 @@ export * from './useResourceDefinitionContext'; export * from './useResourceContext'; export * from './useResourceDefinition'; export * from './useResourceDefinitions'; +export * from './useGetRecordRepresentation'; diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx new file mode 100644 index 00000000000..1a17328c4fd --- /dev/null +++ b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { useGetRecordRepresentation } from './useGetRecordRepresentation'; +import { ResourceDefinitionContext } from './ResourceDefinitionContext'; + +const UseRecordRepresentation = ({ resource, record }) => { + const getRecordRepresentation = useGetRecordRepresentation(resource); + return <>{getRecordRepresentation(record)}; +}; + +describe('useRecordRepresentation', () => { + it('should return the record id by default', () => { + render( + + ); + screen.getByText('User #123'); + }); + it('should return a record field if the recordRepresentation is a string', () => { + render( + {}, + unregister: () => {}, + }} + > + + + ); + screen.getByText('Doe'); + }); + it('should return a string if the recordRepresentation is a function', () => { + render( + + `${record.first_name} ${record.last_name}`, + }, + }, + register: () => {}, + unregister: () => {}, + }} + > + + + ); + screen.getByText('John Doe'); + }); + it('should return a React element if the recordRepresentation is a react element', () => { + const Hello = () =>
Hello
; + render( + , + }, + }, + register: () => {}, + unregister: () => {}, + }} + > + + + ); + screen.getByText('Hello'); + }); +}); diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.ts b/packages/ra-core/src/core/useGetRecordRepresentation.ts new file mode 100644 index 00000000000..ff210262a7a --- /dev/null +++ b/packages/ra-core/src/core/useGetRecordRepresentation.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { useCallback, ReactNode } from 'react'; +import inflection from 'inflection'; + +import { useResourceDefinition } from './useResourceDefinition'; + +/** + * Get default string representation of a record + * + * @example + * const getRecordRepresentation = useGetRecordRepresentation('posts'); + * getRecordRepresentation({ id: 1, title: 'Hello' }); // => "Post #1" + */ +export const useGetRecordRepresentation = ( + resource: string +): ((record: any) => ReactNode) => { + const { recordRepresentation } = useResourceDefinition({ resource }); + return useCallback( + record => { + if (!record) return ''; + if (typeof recordRepresentation === 'function') { + return recordRepresentation(record); + } + if (typeof recordRepresentation === 'string') { + return record[recordRepresentation]; + } + if (React.isValidElement(recordRepresentation)) { + return React.cloneElement(recordRepresentation); + } + return `${inflection.humanize(inflection.singularize(resource))} #${ + record.id + }`; + }, + [recordRepresentation, resource] + ); +}; diff --git a/packages/ra-core/src/core/useResourceDefinition.ts b/packages/ra-core/src/core/useResourceDefinition.ts index fa37dc2649e..34897a4f821 100644 --- a/packages/ra-core/src/core/useResourceDefinition.ts +++ b/packages/ra-core/src/core/useResourceDefinition.ts @@ -31,7 +31,8 @@ export const useResourceDefinition = ( ): ResourceDefinition => { const resource = useResourceContext(props); const resourceDefinitions = useResourceDefinitions(); - const { hasCreate, hasEdit, hasList, hasShow } = props || {}; + const { hasCreate, hasEdit, hasList, hasShow, recordRepresentation } = + props || {}; const definition = useMemo(() => { return defaults( @@ -41,10 +42,19 @@ export const useResourceDefinition = ( hasEdit, hasList, hasShow, + recordRepresentation, }, resourceDefinitions[resource] ); - }, [resource, resourceDefinitions, hasCreate, hasEdit, hasList, hasShow]); + }, [ + resource, + resourceDefinitions, + hasCreate, + hasEdit, + hasList, + hasShow, + recordRepresentation, + ]); return definition; }; @@ -55,4 +65,8 @@ export interface UseResourceDefinitionOptions { readonly hasEdit?: boolean; readonly hasShow?: boolean; readonly hasCreate?: boolean; + readonly recordRepresentation?: + | string + | React.ReactElement + | ((record: any) => string); } diff --git a/packages/ra-core/src/core/useResourceDefinitions.ts b/packages/ra-core/src/core/useResourceDefinitions.ts index 0638f7a9915..af48e0e2f41 100644 --- a/packages/ra-core/src/core/useResourceDefinitions.ts +++ b/packages/ra-core/src/core/useResourceDefinitions.ts @@ -16,6 +16,7 @@ import { useResourceDefinitionContext } from './useResourceDefinitionContext'; * // hasCreate: true, * // options: {}, * // icon: PostIcon, + * // recordRepresentation: 'title', * // } */ export const useResourceDefinitions = (): ResourceDefinitions => diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index a92c36b57e7..cd12897e3e0 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -259,6 +259,8 @@ export type LegacyDataProvider = ( params: any ) => Promise; +export type RecordToStringFunction = (record: any) => string; + export interface ResourceDefinition { readonly name: string; readonly options?: any; @@ -267,6 +269,10 @@ export interface ResourceDefinition { readonly hasShow?: boolean; readonly hasCreate?: boolean; readonly icon?: any; + readonly recordRepresentation?: + | ReactElement + | RecordToStringFunction + | string; } /** @@ -331,6 +337,7 @@ export interface ResourceProps { edit?: ComponentType | ReactElement; show?: ComponentType | ReactElement; icon?: ComponentType; + recordRepresentation?: ReactElement | RecordToStringFunction | string; options?: ResourceOptions; } From 6c43b587f18753cec9a03667e65795814018d0f4 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 23 Jul 2022 17:23:30 +0200 Subject: [PATCH 02/23] use recordReprentation in ReferenceField --- .../src/field/ReferenceField.stories.tsx | 82 +++++++++++++++++- .../src/field/ReferenceField.tsx | 86 +++++++++---------- 2 files changed, 120 insertions(+), 48 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx index f6a2d84c252..f1d6fd51912 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx @@ -4,10 +4,12 @@ import { CoreAdminContext, RecordContextProvider, ResourceContextProvider, + ResourceDefinitionContextProvider, ListContextProvider, + useRecordContext, } from 'ra-core'; import { createMemoryHistory } from 'history'; -import { ThemeProvider } from '@mui/material'; +import { ThemeProvider, Stack } from '@mui/material'; import { createTheme } from '@mui/material/styles'; import { TextField } from '../field'; @@ -239,3 +241,81 @@ export const InDatagrid = () => ( ); + +const BookDetailsRepresentation = () => { + const record = useRecordContext(); + return ( + <> + Genre: {record.genre}, ISBN:{' '} + {record.ISBN} + + ); +}; +export const RecordRepresentation = () => ( + + + + +
+

Default

+ +
+
+ +

String

+ +
+
+
+ + `Genre: ${record.genre}, ISBN: ${record.ISBN}`, + }, + }} + > +

Function

+ +
+
+
+ + ), + }, + }} + > +

Element

+ +
+
+
+
+
+
+); diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 08c44dd3b95..f49f206c4fe 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -15,6 +15,7 @@ import { useRecordContext, useCreatePath, Identifier, + useGetRecordRepresentation, } from 'ra-core'; import { LinearProgress } from '../layout'; @@ -22,44 +23,31 @@ import { Link } from '../Link'; import { PublicFieldProps, fieldPropTypes, InjectedFieldProps } from './types'; /** - * Fetch reference record, and delegate rendering to child component. + * Fetch reference record, and render its representation, or delegate rendering to child component. * * The reference prop should be the name of one of the components * added as child. * - * @example + * @example // using recordRepresentation + * + * + * @example // using a Field component to represent the record * * * * - * @default - * By default, includes a link to the page of the related record - * (`/users/:userId` in the previous example). + * @example // By default, includes a link to the page of the related record + * // (`/users/:userId` in the previous example). + * // Set the `link` prop to "show" to link to the page instead. + * * - * Set the `link` prop to "show" to link to the page instead. + * @example // You can also prevent `` from adding link to children + * // by setting `link` to false. + * * - * @example - * - * - * - * - * @default - * You can also prevent `` from adding link to children by setting - * `link` to false. - * - * @example - * - * - * - * - * @default - * Alternatively, you can also pass a custom function to `link`. It must take reference and record - * as arguments and return a string - * - * @example - * "/path/to/${reference}/${record}"}> - * - * + * @example // Alternatively, you can also pass a custom function to `link`. + * // It must take reference and record as arguments and return a string + * "/path/to/${reference}/${record}"} /> * * @default * In previous versions of React-Admin, the prop `linkType` was used. It is now deprecated and replaced with `link`. However @@ -87,7 +75,7 @@ export const ReferenceField: FC = props => { }; ReferenceField.propTypes = { - children: PropTypes.node.isRequired, + children: PropTypes.node, className: PropTypes.string, cellClassName: PropTypes.string, headerClassName: PropTypes.string, @@ -114,7 +102,7 @@ ReferenceField.defaultProps = { export interface ReferenceFieldProps extends PublicFieldProps, InjectedFieldProps { - children: ReactNode; + children?: ReactNode; reference: string; resource?: string; source: string; @@ -170,10 +158,12 @@ export const ReferenceFieldView: FC = props => { emptyText, error, isLoading, + reference, referenceRecord, resourceLinkPath, sx, } = props; + const getRecordRepresentation = useGetRecordRepresentation(reference); if (error) { return ( @@ -194,25 +184,27 @@ export const ReferenceFieldView: FC = props => { return emptyText ? <>{emptyText} : null; } - if (resourceLinkPath) { - return ( - - - - {children} - - - - ); - } + let child = children || ( + + {getRecordRepresentation(referenceRecord)} + + ); - return ( + return resourceLinkPath ? ( + + + + {child} + + + + ) : ( - {children} + {child} ); }; From fb04ed430b16a4e496072b15ee00307ec3d7bb39 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 26 Jul 2022 15:01:22 +0200 Subject: [PATCH 03/23] use recordRepresentation in Show and Edit page title --- examples/simple/src/comments/CommentEdit.tsx | 2 +- examples/simple/src/comments/CommentList.tsx | 4 +--- examples/simple/src/i18n/en.ts | 3 --- examples/simple/src/i18n/fr.ts | 3 --- examples/simple/src/posts/PostTitle.tsx | 4 ++-- examples/simple/src/posts/index.tsx | 1 + examples/simple/src/tags/index.tsx | 1 + examples/simple/src/users/UserEdit.tsx | 3 +-- examples/simple/src/users/UserShow.tsx | 3 +-- examples/simple/src/users/UserTitle.tsx | 14 ----------- examples/simple/src/users/index.tsx | 1 + .../src/controller/edit/useEditController.ts | 12 +++++++++- .../src/controller/show/useShowController.ts | 14 ++++++++--- .../core/useGetRecordRepresentation.spec.tsx | 24 ++++++++++++++++++- .../src/core/useGetRecordRepresentation.ts | 18 +++++++------- packages/ra-language-english/src/index.ts | 4 ++-- packages/ra-language-french/src/index.ts | 4 ++-- 17 files changed, 68 insertions(+), 47 deletions(-) delete mode 100644 examples/simple/src/users/UserTitle.tsx diff --git a/examples/simple/src/comments/CommentEdit.tsx b/examples/simple/src/comments/CommentEdit.tsx index 5a992f7b94d..5c45ede1145 100644 --- a/examples/simple/src/comments/CommentEdit.tsx +++ b/examples/simple/src/comments/CommentEdit.tsx @@ -113,7 +113,7 @@ const CommentEdit = props => { return (
- + <Title defaultTitle={controllerProps.defaultTitle} /> <Box sx={{ float: 'right' }}> <TopToolbar> <ShowButton record={record} /> diff --git a/examples/simple/src/comments/CommentList.tsx b/examples/simple/src/comments/CommentList.tsx index 5ea0df3150a..566ad76e9c2 100644 --- a/examples/simple/src/comments/CommentList.tsx +++ b/examples/simple/src/comments/CommentList.tsx @@ -121,9 +121,7 @@ const CommentGrid = () => { record={record} source="post_id" reference="posts" - > - <TextField source="title" /> - </ReferenceField> + /> </CardContent> <CardActions sx={{ justifyContent: 'flex-end' }}> <EditButton record={record} /> diff --git a/examples/simple/src/i18n/en.ts b/examples/simple/src/i18n/en.ts index 5a17bc82091..46a5a2d415d 100644 --- a/examples/simple/src/i18n/en.ts +++ b/examples/simple/src/i18n/en.ts @@ -68,9 +68,6 @@ export const messages = { summary: 'Summary', security: 'Security', }, - edit: { - title: 'User "%{title}"', - }, action: { save_and_add: 'Save and Add', save_and_show: 'Save and Show', diff --git a/examples/simple/src/i18n/fr.ts b/examples/simple/src/i18n/fr.ts index 537415fb17d..decd8e6fe25 100644 --- a/examples/simple/src/i18n/fr.ts +++ b/examples/simple/src/i18n/fr.ts @@ -83,8 +83,5 @@ export default { summary: 'Résumé', security: 'Sécurité', }, - edit: { - title: 'Utilisateur "%{title}"', - }, }, }; diff --git a/examples/simple/src/posts/PostTitle.tsx b/examples/simple/src/posts/PostTitle.tsx index 466ffa436ea..694ffc62267 100644 --- a/examples/simple/src/posts/PostTitle.tsx +++ b/examples/simple/src/posts/PostTitle.tsx @@ -5,10 +5,10 @@ export default () => { const translate = useTranslate(); const record = useRecordContext(); return ( - <span> + <> {record ? translate('post.edit.title', { title: record.title }) : ''} - </span> + </> ); }; diff --git a/examples/simple/src/posts/index.tsx b/examples/simple/src/posts/index.tsx index b909fc9b996..35ccd298d26 100644 --- a/examples/simple/src/posts/index.tsx +++ b/examples/simple/src/posts/index.tsx @@ -10,4 +10,5 @@ export default { edit: PostEdit, show: PostShow, icon: BookIcon, + recordRepresentation: 'title', }; diff --git a/examples/simple/src/tags/index.tsx b/examples/simple/src/tags/index.tsx index 9557a13fc0a..f48d9e37e63 100644 --- a/examples/simple/src/tags/index.tsx +++ b/examples/simple/src/tags/index.tsx @@ -8,4 +8,5 @@ export default { edit: TagEdit, list: TagList, show: TagShow, + recordRepresentation: 'name.en', }; diff --git a/examples/simple/src/users/UserEdit.tsx b/examples/simple/src/users/UserEdit.tsx index 582968773ca..232fca45506 100644 --- a/examples/simple/src/users/UserEdit.tsx +++ b/examples/simple/src/users/UserEdit.tsx @@ -16,7 +16,6 @@ import { usePermissions, } from 'react-admin'; -import UserTitle from './UserTitle'; import Aside from './Aside'; /** @@ -92,7 +91,7 @@ const UserEditForm = ({ save, ...props }: { save?: any }) => { }; const UserEdit = () => { return ( - <Edit title={<UserTitle />} aside={<Aside />} actions={<EditActions />}> + <Edit aside={<Aside />} actions={<EditActions />}> <UserEditForm /> </Edit> ); diff --git a/examples/simple/src/users/UserShow.tsx b/examples/simple/src/users/UserShow.tsx index 9a2a16cbc4d..ee97277ea58 100644 --- a/examples/simple/src/users/UserShow.tsx +++ b/examples/simple/src/users/UserShow.tsx @@ -8,13 +8,12 @@ import { usePermissions, } from 'react-admin'; -import UserTitle from './UserTitle'; import Aside from './Aside'; const UserShow = () => { const { permissions } = usePermissions(); return ( - <Show title={<UserTitle />}> + <Show> <TabbedShowLayout> <Tab label="user.form.summary"> <TextField source="id" /> diff --git a/examples/simple/src/users/UserTitle.tsx b/examples/simple/src/users/UserTitle.tsx deleted file mode 100644 index a41d1954047..00000000000 --- a/examples/simple/src/users/UserTitle.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint react/jsx-key: off */ -import * as React from 'react'; -import { RaRecord, useTranslate } from 'react-admin'; - -const UserTitle = ({ record }: { record?: RaRecord }) => { - const translate = useTranslate(); - return ( - <span> - {record ? translate('user.edit.title', { title: record.name }) : ''} - </span> - ); -}; - -export default UserTitle; diff --git a/examples/simple/src/users/index.tsx b/examples/simple/src/users/index.tsx index 411828e14de..ac76e0315e8 100644 --- a/examples/simple/src/users/index.tsx +++ b/examples/simple/src/users/index.tsx @@ -10,4 +10,5 @@ export default { edit: UserEdit, show: UserShow, icon: PeopleIcon, + recordRepresentation: record => `${record.name} (${record.role})`, }; diff --git a/packages/ra-core/src/controller/edit/useEditController.ts b/packages/ra-core/src/controller/edit/useEditController.ts index 8e3911e1bf9..16c4ba184f1 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -14,7 +14,11 @@ import { UseUpdateMutateParams, } from '../../dataProvider'; import { useTranslate } from '../../i18n'; -import { useResourceContext, useGetResourceLabel } from '../../core'; +import { + useResourceContext, + useGetResourceLabel, + useGetRecordRepresentation, +} from '../../core'; import { SaveContextValue, useMutationMiddlewares } from '../saveContext'; /** @@ -56,6 +60,7 @@ export const useEditController = < } = props; useAuthenticated({ enabled: !disableAuthentication }); const resource = useResourceContext(props); + const getRecordRepresentation = useGetRecordRepresentation(resource); const translate = useTranslate(); const notify = useNotify(); const redirect = useRedirect(); @@ -102,10 +107,15 @@ export const useEditController = < } const getResourceLabel = useGetResourceLabel(); + const recordRepresentation = getRecordRepresentation(record); const defaultTitle = translate('ra.page.edit', { name: getResourceLabel(resource, 1), id, record, + recordRepresentation: + typeof recordRepresentation === 'string' + ? recordRepresentation + : '', }); const recordCached = { id, previousData: record }; diff --git a/packages/ra-core/src/controller/show/useShowController.ts b/packages/ra-core/src/controller/show/useShowController.ts index 64e19b6e533..ff0d9a6b5ec 100644 --- a/packages/ra-core/src/controller/show/useShowController.ts +++ b/packages/ra-core/src/controller/show/useShowController.ts @@ -7,7 +7,11 @@ import { useGetOne, useRefresh, UseGetOneHookValue } from '../../dataProvider'; import { useTranslate } from '../../i18n'; import { useRedirect } from '../../routing'; import { useNotify } from '../../notification'; -import { useResourceContext, useGetResourceLabel } from '../../core'; +import { + useResourceContext, + useGetResourceLabel, + useGetRecordRepresentation, +} from '../../core'; /** * Prepare data for the Show view. @@ -45,10 +49,9 @@ export const useShowController = <RecordType extends RaRecord = any>( props: ShowControllerProps<RecordType> = {} ): ShowControllerResult<RecordType> => { const { disableAuthentication, id: propsId, queryOptions = {} } = props; - useAuthenticated({ enabled: !disableAuthentication }); - const resource = useResourceContext(props); + const getRecordRepresentation = useGetRecordRepresentation(resource); const translate = useTranslate(); const notify = useNotify(); const redirect = useRedirect(); @@ -83,10 +86,15 @@ export const useShowController = <RecordType extends RaRecord = any>( } const getResourceLabel = useGetResourceLabel(); + const recordRepresentation = getRecordRepresentation(record); const defaultTitle = translate('ra.page.show', { name: getResourceLabel(resource, 1), id, record, + recordRepresentation: + typeof recordRepresentation === 'string' + ? recordRepresentation + : '', }); return { diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx index 1a17328c4fd..7853fee1c13 100644 --- a/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx +++ b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx @@ -14,7 +14,7 @@ describe('useRecordRepresentation', () => { render( <UseRecordRepresentation resource="users" record={{ id: 123 }} /> ); - screen.getByText('User #123'); + screen.getByText('#123'); }); it('should return a record field if the recordRepresentation is a string', () => { render( @@ -38,6 +38,28 @@ describe('useRecordRepresentation', () => { ); screen.getByText('Doe'); }); + it('should return a deep record field if the recordRepresentation is a string with a dot', () => { + render( + <ResourceDefinitionContext.Provider + value={{ + definitions: { + users: { + name: 'users', + recordRepresentation: 'name.last', + }, + }, + register: () => {}, + unregister: () => {}, + }} + > + <UseRecordRepresentation + resource="users" + record={{ id: 123, name: { first: 'John', last: 'Doe' } }} + /> + </ResourceDefinitionContext.Provider> + ); + screen.getByText('Doe'); + }); it('should return a string if the recordRepresentation is a function', () => { render( <ResourceDefinitionContext.Provider diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.ts b/packages/ra-core/src/core/useGetRecordRepresentation.ts index ff210262a7a..f37c5aa9e09 100644 --- a/packages/ra-core/src/core/useGetRecordRepresentation.ts +++ b/packages/ra-core/src/core/useGetRecordRepresentation.ts @@ -1,15 +1,19 @@ import * as React from 'react'; import { useCallback, ReactNode } from 'react'; -import inflection from 'inflection'; +import get from 'lodash/get'; import { useResourceDefinition } from './useResourceDefinition'; /** * Get default string representation of a record * - * @example + * @example // No customization * const getRecordRepresentation = useGetRecordRepresentation('posts'); - * getRecordRepresentation({ id: 1, title: 'Hello' }); // => "Post #1" + * getRecordRepresentation({ id: 1, title: 'Hello' }); // => "#1" + * + * @example // With <Resource name="posts" recordRepresentation="title" /> + * const getRecordRepresentation = useGetRecordRepresentation('posts'); + * getRecordRepresentation({ id: 1, title: 'Hello' }); // => "Hello" */ export const useGetRecordRepresentation = ( resource: string @@ -22,15 +26,13 @@ export const useGetRecordRepresentation = ( return recordRepresentation(record); } if (typeof recordRepresentation === 'string') { - return record[recordRepresentation]; + return get(record, recordRepresentation); } if (React.isValidElement(recordRepresentation)) { return React.cloneElement(recordRepresentation); } - return `${inflection.humanize(inflection.singularize(resource))} #${ - record.id - }`; + return `#${record.id}`; }, - [recordRepresentation, resource] + [recordRepresentation] ); }; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index e923d2934a7..45e294122f6 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -46,12 +46,12 @@ const englishMessages: TranslationMessages = { page: { create: 'Create %{name}', dashboard: 'Dashboard', - edit: '%{name} #%{id}', + edit: '%{name} %{recordRepresentation}', error: 'Something went wrong', list: '%{name}', loading: 'Loading', not_found: 'Not Found', - show: '%{name} #%{id}', + show: '%{name} %{recordRepresentation}', empty: 'No %{name} yet.', invite: 'Do you want to add one?', }, diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index a73bf9e662d..e19b968aaf9 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -47,12 +47,12 @@ const frenchMessages: TranslationMessages = { page: { create: 'Créer %{name}', dashboard: 'Tableau de bord', - edit: '%{name} #%{id}', + edit: '%{name} %{recordRepresentation}', error: 'Un problème est survenu', list: '%{name}', loading: 'Chargement', not_found: 'Page manquante', - show: '%{name} #%{id}', + show: '%{name} %{recordRepresentation}', empty: 'Pas encore de %{name}.', invite: 'Voulez-vous en créer un ?', }, From 406614eed0f01f18ef5f99184fe95fe476809fc9 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 16:20:05 +0200 Subject: [PATCH 04/23] Update documentation --- docs/ReferenceField.md | 72 +++++++++++++++++++----------------------- docs/Resource.md | 21 ++++++++++++ docs/Tutorial.md | 62 ++++++++++++++---------------------- 3 files changed, 77 insertions(+), 78 deletions(-) diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index 94001dc00e6..1b5e6b4b59c 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -22,7 +22,21 @@ For instance, let's consider a model where a `post` has one author from the `use └──────────────┘ ``` -In that case, use `<ReferenceField>` to display the post author's name as follows: +In that case, use `<ReferenceField>` to display the post author's id as follows: + +```jsx +<ReferenceField source="user_id" reference="users" /> +``` + +`<ReferenceField>` fetches the data, puts it in a [`RecordContext`](./useRecordContext.md), and renders the [`recordRepresentation`](./Resource.md#recordrepresentation) (the record `id` field by default). + +So it's a good idea to configure the `<Resource recordRepresentation>` to render related records in a meaningul way. For instance, for the `users` resource, if you want the `<ReferenceField>` to display the full name of the author: + +```jsx +<Resource name="users" list={UserList} recordRepresentation={(record) => `${record.first_name} ${record.last_name}`} /> +``` + +Alternately, if you pass a child component, `ReferenceField>` will render it instead of the `recordRepresentation`. Usual child components for `<ReferenceField>` are other `<Field>` components (e.g. [`<TextField>`](./TextField.md)). ```jsx <ReferenceField source="user_id" reference="users"> @@ -30,15 +44,13 @@ In that case, use `<ReferenceField>` to display the post author's name as follow </ReferenceField> ``` -A `<ReferenceField>` displays nothing on its own, it just fetches the data, puts it in a [`RecordContext`](./useRecordContext.md), and lets its children render it. Usual child components for `<ReferenceField>` are other `<Field>` components (e.g. [`<TextField>`](./TextField.md)). - This component fetches a referenced record (`users` in this example) using the `dataProvider.getMany()` method, and passes it to its child. -It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` for performance reasons. When using several `<ReferenceField>` in the same page (e.g. in a `<Datagrid>`), this allows to call the `dataProvider` once instead of once per row. +It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for performance reasons](#performance). When using several `<ReferenceField>` in the same page (e.g. in a `<Datagrid>`), this allows to call the `dataProvider` once instead of once per row. ## Usage -Here is how to render both a post and the `name` of its author in a show view: +Here is how to render both a post and the author name in a show view: ```jsx import { Show, SimpleShowLayout, ReferenceField, TextField, DateField } from 'react-admin'; @@ -49,9 +61,7 @@ export const PostShow = () => ( <TextField source="id" /> <TextField source="title" /> <DateField source="published_at" /> - <ReferenceField label="Author" source="user_id" reference="users"> - <TextField source="name" /> - </ReferenceField> + <ReferenceField label="Author" source="user_id" reference="users" /> </SimpleShowLayout> </Show> ); @@ -65,7 +75,7 @@ With this configuration, `<ReferenceField>` wraps the user's name in a link to t | ----------- | -------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | | `source` | Required | `string` | - | Name of the property to display | | `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'posts' | -| `children` | Required | `ReactNode` | - | One or more Field elements used to render the referenced record | +| `children` | Optional | `ReactNode` | - | One or more Field elements used to render the referenced record | | `emptyText` | Optional | `string` | '' | Defines a text to be shown when the field has no value or when the reference is missing | | `label` | Optional | `string | Function` | `resources.[resource].fields.[source]` | Label to use for the field when rendered in layout components | | `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | @@ -78,9 +88,7 @@ With this configuration, `<ReferenceField>` wraps the user's name in a link to t `<ReferenceField>` can display a custom message when the referenced record is missing, thanks to the `emptyText` prop. ```jsx -<ReferenceField source="user_id" reference="users" emptyText="Missing user"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" emptyText="Missing user" /> ``` `<ReferenceField>` renders the `emptyText`: @@ -94,25 +102,19 @@ By default, `<SimpleShowLayout>`, `<Datagrid>` and other layout components infer ```jsx {/* default label is 'User Id', or the translation of 'resources.posts.fields.user_id' if it exists */} -<ReferenceField source="user_id" reference="users"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" /> ``` That's why you often need to set an explicit `label` on a `<ReferenceField>`: ```jsx -<ReferenceField label="Author name" source="user_id" reference="users"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField label="Author name" source="user_id" reference="users" /> ``` React-admin uses [the i18n system](./Translation.md) to translate the label, so you can use translation keys to have one label for each language supported by the interface: ```jsx -<ReferenceField label="resources.posts.fields.author" source="user_id" reference="users"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField label="resources.posts.fields.author" source="user_id" reference="users" /> ``` ## `link` @@ -120,27 +122,25 @@ React-admin uses [the i18n system](./Translation.md) to translate the label, so To change the link from the `<Edit>` page to the `<Show>` page, set the `link` prop to "show". ```jsx -<ReferenceField source="user_id" reference="users" link="show"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" link="show" /> ``` You can also prevent `<ReferenceField>` from adding a link to children by setting `link` to `false`. ```jsx // No link -<ReferenceField source="user_id" reference="users" link={false}> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" link={false} /> ``` You can also use a custom `link` function to get a custom path for the children. This function must accept `record` and `reference` as arguments. ```jsx // Custom path -<ReferenceField source="user_id" reference="users" link={(record, reference) => `/my/path/to/${reference}/${record.id}`}> - <TextField source="name" /> -</ReferenceField> +<ReferenceField + source="user_id" + reference="users" + link={(record, reference) => `/my/path/to/${reference}/${record.id}`} +/> ``` ## `reference` @@ -150,9 +150,7 @@ The resource to fetch for the related record. For instance, if the `posts` resource has a `user_id` field, set the `reference` to `users` to fetch the user related to each post. ```jsx -<ReferenceField source="user_id" reference="users"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" /> ``` ## `sortBy` @@ -160,9 +158,7 @@ For instance, if the `posts` resource has a `user_id` field, set the `reference` By default, when used in a `<Datagrid>`, and when the user clicks on the column header of a `<ReferenceField>`, react-admin sorts the list by the field `source`. To specify another field name to sort by, set the `sortBy` prop. ```jsx -<ReferenceField source="user_id" reference="users" sortBy="user.name"> - <TextField source="name" /> -</ReferenceField> +<ReferenceField source="user_id" reference="users" sortBy="user.name" /> ``` ## `sx`: CSS API @@ -255,9 +251,7 @@ export const PostList = () => ( <List> <Datagrid> <TextField source="id" /> - <ReferenceField label="User" source="user_id" reference="users"> - <TextField source="name" /> - </ReferenceField> + <ReferenceField label="User" source="user_id" reference="users" /> <TextField source="title" /> <EditButton /> </Datagrid> diff --git a/docs/Resource.md b/docs/Resource.md index fa82f1aeb0e..0ea16f34230 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -84,6 +84,7 @@ The routing will map the component as follows: * [`name`](#name) * [`icon`](#icon) * [`options`](#icon) +* [`recordRepresentation`](#recordrepresentation) ## `icon` @@ -117,6 +118,26 @@ const App = () => ( ``` {% endraw %} +## `recordRepresentation` + +Whenever react-admin needs to render a record (e.g. in the title of an edition view, or in a `<ReferenceField>`), it uses the `recordRepresentation` to do it. By default, the representation of a record is its `id` field. But you can customize it by specifying the representation you want. + +For instance, to change the default represnetation of "users" records to render the full name instead of the id: + +```jsx +<Resource + name="users" + list={UserList} + recordRepresentation={(record) => `${record.first_name} ${record.last_name}`} +/> +``` + +`recordReprensentation` can take 3 types of values: + +- a string (e.g. `'title'`) to specify the field to use as representation +- a function (e.g. `(record) => record.title`) to specify a custom string representation +- a React component (e.g. `<MyCustomRecordRepresentation />`). In such components, use [`useRecordContext`](./useRecordContext.md) to access the record. + ## Resource Context `<Resource>` also creates a `ResourceContext`, that gives access to the current resource name to all descendants of the main page components (`list`, `create`, `edit`, `show`). diff --git a/docs/Tutorial.md b/docs/Tutorial.md index 1725444ef83..98d84ebaa51 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -361,9 +361,7 @@ import { List, Datagrid, TextField, ReferenceField } from 'react-admin'; export const PostList = () => ( <List> <Datagrid rowClick="edit"> - <ReferenceField source="userId" reference="users"> - <TextField source="id" /> - </ReferenceField> + <ReferenceField source="userId" reference="users" /> <TextField source="id" /> <TextField source="title" /> <TextField source="body" /> @@ -388,22 +386,16 @@ const App = () => ( ); ``` -When displaying the posts list, the app displays the `id` of the post author as a `<TextField>`. This `id` field doesn't mean much, let's use the user `name` instead: +When displaying the posts list, the app displays the `id` of the post author as a `<TextField>`. This `id` field doesn't mean much, let's use the user `name` instead. For that purpose, set the `recordRepresentation` prop of the users Resource: ```diff -// in src/posts.js -export const PostList = () => ( - <List> - <Datagrid rowClick="edit"> - <ReferenceField source="userId" reference="users"> -- <TextField source="id" /> -+ <TextField source="name" /> - </ReferenceField> - <TextField source="id" /> - <TextField source="title" /> - <TextField source="body" /> - </Datagrid> - </List> +// in src/App.js +const App = () => ( + <Admin dataProvider={dataProvider}> + <Resource name="posts" list={PostList} /> +- <Resource name="users" list={UserList} /> ++ <Resource name="users" list={UserList} recordRepresentation="name" /> + </Admin> ); ``` @@ -411,7 +403,7 @@ The post list now displays the user names on each line. [![Post List With User Names](./img/tutorial_list_user_name.png)](./img/tutorial_list_user_name.png) -The `<ReferenceField>` component alone doesn't display anything. It just fetches the reference data, creates a `RecordContext` with the result, and renders its children (a `<TextField>` in this case). +The `<ReferenceField>` component fetches the reference data, creates a `RecordContext` with the result, and renders the record representation (or its its children). **Tip**: Look at the network tab of your browser again: react-admin deduplicates requests for users, and aggregates them in order to make only *one* HTTP request to the `/users` endpoint for the whole Datagrid. That's one of many optimizations that keep the UI fast and responsive. @@ -428,9 +420,7 @@ export const PostList = () => ( - <Datagrid rowClick="edit"> + <Datagrid> + <TextField source="id" /> - <ReferenceField source="userId" reference="users"> - <TextField source="name" /> - </ReferenceField> + <ReferenceField source="userId" reference="users" /> - <TextField source="id" /> <TextField source="title" /> - <TextField source="body" /> @@ -457,7 +447,7 @@ const App = () => ( <Admin dataProvider={dataProvider}> - <Resource name="posts" list={PostList} /> + <Resource name="posts" list={PostList} edit={EditGuesser} /> - <Resource name="users" list={UserList} /> + <Resource name="users" list={UserList} recordRepresentation="name" /> </Admin> ); ``` @@ -585,7 +575,7 @@ const App = () => ( <Admin dataProvider={dataProvider}> - <Resource name="posts" list={PostList} edit={EditGuesser} /> + <Resource name="posts" list={PostList} edit={PostEdit} create={PostCreate} /> - <Resource name="users" list={UserList} /> + <Resource name="users" list={UserList} recordRepresentation="name" /> </Admin> ); ``` @@ -614,7 +604,7 @@ Optimistic updates and undo require no specific code on the API side - react-adm ## Customizing The Page Title -The post editing page has a slight problem: it uses the post id as main title (the text displayed in the top bar). +The post editing page has a slight problem: it uses the post id as main title (the text displayed in the top bar). We could set a custom `recordRepresentation` in the `<Resource name="posts">` component, but it's limited to rendering a string. Let's customize the view title with a custom title component: @@ -683,7 +673,7 @@ import UserIcon from '@mui/icons-material/Group'; const App = () => ( <Admin dataProvider={dataProvider}> <Resource name="posts" list={PostList} edit={PostEdit} create={PostCreate} icon={PostIcon} /> - <Resource name="users" list={UserList} icon={UserIcon} /> + <Resource name="users" list={UserList} icon={UserIcon} recordRepresentation="name" /> </Admin> ); ``` @@ -805,9 +795,7 @@ export const PostList = () => ( <SimpleList primaryText={record => record.title} secondaryText={record => ( - <ReferenceField label="User" source="userId" reference="users"> - <TextField source="name" /> - </ReferenceField> + <ReferenceField label="User" source="userId" reference="users" /> )} /> </List> @@ -833,20 +821,16 @@ export const PostList = () => { return ( <List> {isSmall ? ( - <SimpleList - primaryText={record => record.title} - secondaryText={record => ( - <ReferenceField label="User" source="userId" reference="users"> - <TextField source="name" /> - </ReferenceField> - )} - /> + <SimpleList + primaryText={record => record.title} + secondaryText={record => ( + <ReferenceField label="User" source="userId" reference="users" /> + )} + /> ) : ( <Datagrid> <TextField source="id" /> - <ReferenceField label="User" source="userId" reference="users"> - <TextField source="name" /> - </ReferenceField> + <ReferenceField label="User" source="userId" reference="users" /> <TextField source="title" /> <TextField source="body" /> <EditButton /> From e442c5d19f57030e17d07e635d3ce6a2637736ac Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 16:27:53 +0200 Subject: [PATCH 05/23] Improve inference --- examples/simple/src/comments/index.tsx | 3 ++- .../inference/inferElementFromValues.spec.tsx | 14 ++--------- .../src/inference/inferElementFromValues.tsx | 24 +++++++------------ .../src/detail/showFieldTypes.tsx | 2 +- .../src/list/listFieldTypes.tsx | 2 +- 5 files changed, 14 insertions(+), 31 deletions(-) diff --git a/examples/simple/src/comments/index.tsx b/examples/simple/src/comments/index.tsx index e8d6f9dbdd0..615907ee81e 100644 --- a/examples/simple/src/comments/index.tsx +++ b/examples/simple/src/comments/index.tsx @@ -3,9 +3,10 @@ import CommentCreate from './CommentCreate'; import CommentEdit from './CommentEdit'; import CommentList from './CommentList'; import CommentShow from './CommentShow'; +import { ListGuesser } from 'react-admin'; export default { - list: CommentList, + list: ListGuesser, create: CommentCreate, edit: CommentEdit, show: CommentShow, diff --git a/packages/ra-core/src/inference/inferElementFromValues.spec.tsx b/packages/ra-core/src/inference/inferElementFromValues.spec.tsx index 74bf6e5d7e2..12c7fca04e6 100644 --- a/packages/ra-core/src/inference/inferElementFromValues.spec.tsx +++ b/packages/ra-core/src/inference/inferElementFromValues.spec.tsx @@ -41,29 +41,19 @@ describe('inferElementFromValues', () => { const types = { reference: { component: Good }, string: { component: Bad }, - referenceChild: { component: Dummy }, }; expect( inferElementFromValues('foo_id', ['foo', 'bar'], types).getElement() - ).toEqual( - <Good source="foo_id" reference="foos"> - <Dummy /> - </Good> - ); + ).toEqual(<Good source="foo_id" reference="foos" />); }); it('should return a reference field for field named *Id', () => { const types = { reference: { component: Good }, string: { component: Bad }, - referenceChild: { component: Dummy }, }; expect( inferElementFromValues('fooId', ['foo', 'bar'], types).getElement() - ).toEqual( - <Good source="fooId" reference="foos"> - <Dummy /> - </Good> - ); + ).toEqual(<Good source="fooId" reference="foos" />); }); it('should return a reference array field for field named *_ids', () => { const types = { diff --git a/packages/ra-core/src/inference/inferElementFromValues.tsx b/packages/ra-core/src/inference/inferElementFromValues.tsx index 18f0de37b06..0f0ee740e49 100644 --- a/packages/ra-core/src/inference/inferElementFromValues.tsx +++ b/packages/ra-core/src/inference/inferElementFromValues.tsx @@ -89,28 +89,20 @@ const inferElementFromValues = ( const reference = inflection.pluralize(name.substr(0, name.length - 3)); return ( types.reference && - new InferredElement( - types.reference, - { - source: name, - reference, - }, - new InferredElement(types.referenceChild) - ) + new InferredElement(types.reference, { + source: name, + reference, + }) ); } if (name.substr(name.length - 2) === 'Id' && hasType('reference', types)) { const reference = inflection.pluralize(name.substr(0, name.length - 2)); return ( types.reference && - new InferredElement( - types.reference, - { - source: name, - reference, - }, - new InferredElement(types.referenceChild) - ) + new InferredElement(types.reference, { + source: name, + reference, + }) ); } if ( diff --git a/packages/ra-ui-materialui/src/detail/showFieldTypes.tsx b/packages/ra-ui-materialui/src/detail/showFieldTypes.tsx index a42d7491d5e..a67622aba61 100644 --- a/packages/ra-ui-materialui/src/detail/showFieldTypes.tsx +++ b/packages/ra-ui-materialui/src/detail/showFieldTypes.tsx @@ -72,7 +72,7 @@ ${children.map(child => ` ${child.getRepresentation()}`).join('\n')} reference: { component: ReferenceField, representation: (props: ReferenceFieldProps) => - `<ReferenceField source="${props.source}" reference="${props.reference}"><TextField source="id" /></ReferenceField>`, + `<ReferenceField source="${props.source}" reference="${props.reference}" />`, }, referenceChild: { component: (props: { children: ReactNode } & InputProps) => ( diff --git a/packages/ra-ui-materialui/src/list/listFieldTypes.tsx b/packages/ra-ui-materialui/src/list/listFieldTypes.tsx index c7b3128beef..57083be03a1 100644 --- a/packages/ra-ui-materialui/src/list/listFieldTypes.tsx +++ b/packages/ra-ui-materialui/src/list/listFieldTypes.tsx @@ -62,7 +62,7 @@ ${children.map(child => ` ${child.getRepresentation()}`).join('\n')} reference: { component: ReferenceField, representation: props => - `<ReferenceField source="${props.source}" reference="${props.reference}"><TextField source="id" /></ReferenceField>`, + `<ReferenceField source="${props.source}" reference="${props.reference}" />`, }, referenceChild: { component: props => <TextField source="id" {...props} />, // eslint-disable-line react/display-name From 671a930166ef49c7756a9f839859097e3e3312a4 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 16:31:42 +0200 Subject: [PATCH 06/23] Fix tutorial --- docs/Tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Tutorial.md b/docs/Tutorial.md index 98d84ebaa51..60db5ed3c26 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -386,7 +386,7 @@ const App = () => ( ); ``` -When displaying the posts list, the app displays the `id` of the post author as a `<TextField>`. This `id` field doesn't mean much, let's use the user `name` instead. For that purpose, set the `recordRepresentation` prop of the users Resource: +When displaying the posts list, the app displays the `id` of the post author. This doesn't mean much, let's use the user `name` instead. For that purpose, set the `recordRepresentation` prop of the "users" Resource: ```diff // in src/App.js From 0ec088b7f364722a1356af1e662e69f0e2612834 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 16:44:27 +0200 Subject: [PATCH 07/23] Use recordRepresentation in RefrenceOneField --- docs/ReferenceOneField.md | 4 +- .../src/field/ReferenceOneField.stories.tsx | 82 ++++++++++++++++++- .../src/field/ReferenceOneField.tsx | 4 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/docs/ReferenceOneField.md b/docs/ReferenceOneField.md index 354a0ba082d..acda54df06b 100644 --- a/docs/ReferenceOneField.md +++ b/docs/ReferenceOneField.md @@ -20,7 +20,7 @@ This field fetches a one-to-one relationship, e.g. the details of a book, when u `<ReferenceOneField>` behaves like `<ReferenceManyField>`: it uses the current `record` (a book in this example) to build a filter for the book details with the foreign key (`book_id`). Then, it uses `dataProvider.getManyReference('book_details', { target: 'book_id', id: book.id })` to fetch the related details, and takes the first one. -`<ReferenceOneField>` creates a `RecordContext` with the reference record, so you can use any component relying on this context (`<TextField>`, `<SimpleShowLayout>`, etc.). +`<ReferenceOneField>` renders the [`recordRepresentation`](./Resource.md#recordrepresentation) of the related record. It also creates a `RecordContext` with the reference record, so you can use any component relying on this context (`<TextField>`, `<SimpleShowLayout>`, etc.) as child. For the inverse relationships (the book linked to a book_detail), you can use a [`<ReferenceField>`](./ReferenceField.md). @@ -51,7 +51,7 @@ const BookShow = () => ( | Prop | Required | Type | Default | Description | | ------------ | -------- | ------------------ | -------------------------------- | ----------------------------------------------------------------------------------- | -| `children` | Required | `Element` | - | The Field element used to render the referenced record | +| `children` | Optional | `Element` | - | The Field element used to render the referenced record | | `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | | `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'book_details' | | `target` | Required | string | - | Target field carrying the relationship on the referenced resource, e.g. 'book_id' | diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx index c7435467524..3e02b1016d1 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx @@ -4,10 +4,12 @@ import { CoreAdminContext, RecordContextProvider, ResourceContextProvider, + ResourceDefinitionContextProvider, ListContextProvider, + useRecordContext, } from 'ra-core'; import { createMemoryHistory } from 'history'; -import { ThemeProvider } from '@mui/material'; +import { ThemeProvider, Stack } from '@mui/material'; import { createTheme } from '@mui/material/styles'; import { TextField } from '../field'; @@ -193,3 +195,81 @@ export const InDatagrid = () => ( </Datagrid> </ListWrapper> ); + +const BookDetailsRepresentation = () => { + const record = useRecordContext(); + return ( + <> + <strong>Genre</strong>: {record.genre}, <strong>ISBN</strong>:{' '} + {record.ISBN} + </> + ); +}; +export const RecordRepresentation = () => ( + <CoreAdminContext dataProvider={defaultDataProvider} history={history}> + <ResourceContextProvider value="books"> + <RecordContextProvider value={{ id: 1, title: 'War and Peace' }}> + <Stack spacing={4} direction="row" sx={{ ml: 2 }}> + <div> + <h3>Default</h3> + <ReferenceOneField + reference="book_details" + target="book_id" + /> + </div> + <div> + <ResourceDefinitionContextProvider + definitions={{ + book_details: { + name: 'book_details', + recordRepresentation: 'ISBN', + }, + }} + > + <h3>String</h3> + <ReferenceOneField + reference="book_details" + target="book_id" + /> + </ResourceDefinitionContextProvider> + </div> + <div> + <ResourceDefinitionContextProvider + definitions={{ + book_details: { + name: 'book_details', + recordRepresentation: record => + `Genre: ${record.genre}, ISBN: ${record.ISBN}`, + }, + }} + > + <h3>Function</h3> + <ReferenceOneField + reference="book_details" + target="book_id" + /> + </ResourceDefinitionContextProvider> + </div> + <div> + <ResourceDefinitionContextProvider + definitions={{ + book_details: { + name: 'book_details', + recordRepresentation: ( + <BookDetailsRepresentation /> + ), + }, + }} + > + <h3>Element</h3> + <ReferenceOneField + reference="book_details" + target="book_id" + /> + </ResourceDefinitionContextProvider> + </div> + </Stack> + </RecordContextProvider> + </ResourceContextProvider> + </CoreAdminContext> +); diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index e2dfb97cb3b..e62a37e6d01 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -85,14 +85,14 @@ export const ReferenceOneField = (props: ReferenceOneFieldProps) => { export interface ReferenceOneFieldProps extends PublicFieldProps, InjectedFieldProps { - children: ReactNode; + children?: ReactNode; reference: string; target: string; link?: LinkToType; } ReferenceOneField.propTypes = { - children: PropTypes.node.isRequired, + children: PropTypes.node, className: PropTypes.string, label: fieldPropTypes.label, record: PropTypes.any, From d9315a74305ec0efe2377648c6349c29bcb2da3b Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 16:57:38 +0200 Subject: [PATCH 08/23] Add base SelectInput stories --- .../src/input/LoadingInput.tsx | 3 + .../src/input/SelectInput.stories.tsx | 140 ++++++++++++++++++ .../src/input/SelectInput.tsx | 1 + 3 files changed, 144 insertions(+) create mode 100644 packages/ra-ui-materialui/src/input/SelectInput.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/LoadingInput.tsx b/packages/ra-ui-materialui/src/input/LoadingInput.tsx index 438626550c3..f34eac7b60c 100644 --- a/packages/ra-ui-materialui/src/input/LoadingInput.tsx +++ b/packages/ra-ui-materialui/src/input/LoadingInput.tsx @@ -11,6 +11,7 @@ import { ResettableTextField } from './ResettableTextField'; * Avoids visual jumps when replaced by a form input */ export const LoadingInput = ({ + fullWidth, label, helperText, margin, @@ -26,6 +27,7 @@ export const LoadingInput = ({ sx={sx} label={label} helperText={helperText} + fullWidth={fullWidth} variant={variant} margin={margin} size={size} @@ -66,6 +68,7 @@ const StyledResettableTextField = styled(ResettableTextField, { })); export interface LoadingInputProps { + fullWidth?: boolean; helperText?: React.ReactNode; margin?: 'normal' | 'none' | 'dense'; label?: string | React.ReactElement | false; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx new file mode 100644 index 00000000000..667ee8b97b5 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { createMemoryHistory } from 'history'; +import { Admin, AdminContext } from 'react-admin'; +import { Resource } from 'ra-core'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { Create, Edit } from '../detail'; +import { SimpleForm } from '../form'; +import { SelectInput } from './SelectInput'; +import { ReferenceInput } from './ReferenceInput'; + +export default { title: 'ra-ui-materialui/input/SelectInput' }; + +export const Basic = () => ( + <Wrapper> + <SelectInput + source="gender" + choices={[ + { id: 'M', name: 'Male ' }, + { id: 'F', name: 'Female' }, + ]} + /> + </Wrapper> +); + +export const Disabled = () => ( + <Wrapper> + <SelectInput + source="gender" + choices={[ + { id: 'M', name: 'Male ' }, + { id: 'F', name: 'Female' }, + ]} + disabled + /> + </Wrapper> +); + +const i18nProvider = polyglotI18nProvider(() => englishMessages); + +const Wrapper = ({ children }) => ( + <AdminContext i18nProvider={i18nProvider}> + <Create resource="posts"> + <SimpleForm>{children}</SimpleForm> + </Create> + </AdminContext> +); + +const authors = [ + { id: 1, name: 'Leo Tolstoy', language: 'Russian' }, + { id: 2, name: 'Victor Hugo', language: 'French' }, + { id: 3, name: 'William Shakespeare', language: 'English' }, + { id: 4, name: 'Charles Baudelaire', language: 'French' }, + { id: 5, name: 'Marcel Proust', language: 'French' }, +]; + +const dataProviderWithAuthors = { + getOne: (resource, params) => + Promise.resolve({ + data: { + id: 1, + title: 'War and Peace', + author: 1, + summary: + "War and Peace broadly focuses on Napoleon's invasion of Russia, and the impact it had on Tsarist society. The book explores themes such as revolution, revolution and empire, the growth and decline of various states and the impact it had on their economies, culture, and society.", + year: 1869, + }, + }), + getMany: (resource, params) => + Promise.resolve({ + data: authors.filter(author => params.ids.includes(author.id)), + }), + getList: (resource, params) => + new Promise(resolve => { + // eslint-disable-next-line eqeqeq + if (params.filter.q == undefined) { + setTimeout( + () => + resolve({ + data: authors, + total: authors.length, + }), + 500 + ); + return; + } + + const filteredAuthors = authors.filter(author => + author.name + .toLowerCase() + .includes(params.filter.q.toLowerCase()) + ); + + setTimeout( + () => + resolve({ + data: filteredAuthors, + total: filteredAuthors.length, + }), + 500 + ); + }), + update: (resource, params) => Promise.resolve(params), + create: (resource, params) => { + const newAuthor = { + id: authors.length + 1, + name: params.data.name, + language: params.data.language, + }; + authors.push(newAuthor); + return Promise.resolve({ data: newAuthor }); + }, +} as any; + +const BookEditWithReference = () => ( + <Edit + mutationMode="pessimistic" + mutationOptions={{ + onSuccess: data => { + console.log(data); + }, + }} + > + <SimpleForm> + <ReferenceInput reference="authors" source="author"> + <SelectInput fullWidth /> + </ReferenceInput> + </SimpleForm> + </Edit> +); + +const history = createMemoryHistory({ initialEntries: ['/books/1'] }); + +export const InsideReferenceInput = () => ( + <Admin dataProvider={dataProviderWithAuthors} history={history}> + <Resource name="authors" /> + <Resource name="books" edit={BookEditWithReference} /> + </Admin> +); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 3ea2b003cf0..37f000f92b6 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -256,6 +256,7 @@ export const SelectInput = (props: SelectInputProps) => { variant={props.variant} size={props.size} margin={props.margin} + fullWidth={props.fullWidth} /> ); } From a66dcae07ebd9a14b541e44258146730b737bbc2 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 17:55:16 +0200 Subject: [PATCH 09/23] Support recordRepresentation in SelectInput --- docs/SelectInput.md | 175 ++++++++++++------ .../input/useReferenceArrayInputController.ts | 1 + .../input/useReferenceInputController.ts | 1 + .../src/form/choices/ChoicesContext.ts | 1 + .../src/form/choices/useChoicesContext.ts | 1 + packages/ra-core/src/form/useChoices.tsx | 2 +- .../src/input/SelectInput.stories.tsx | 53 +++--- .../src/input/SelectInput.tsx | 20 +- 8 files changed, 165 insertions(+), 89 deletions(-) diff --git a/docs/SelectInput.md b/docs/SelectInput.md index 4bcb07bfcc7..19a85881871 100644 --- a/docs/SelectInput.md +++ b/docs/SelectInput.md @@ -9,6 +9,8 @@ To let users choose a value in a list using a dropdown, use `<SelectInput>`. It ![SelectInput](./img/select-input.gif) +## Usage + Set the `choices` attribute to determine the options (with `id`, `name` tuples): ```jsx @@ -21,11 +23,13 @@ import { SelectInput } from 'react-admin'; ]} /> ``` +If, instead of showing choices as a dropdown list, you prefer to display them as a list of radio buttons, try the [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md). And if the list is too big, prefer the [`<AutocompleteInput>`](./AutocompleteInput.md). + ## Properties | Prop | Required | Type | Default | Description | |-------------------|----------|----------------------------|--------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `choices` | Required | `Object[]` | - | List of items to show as options | +| `choices` | Optional | `Object[]` | - | List of items to show as options. Required if not inside a ReferenceInput. | | `create` | Optional | `Element` | `-` | A React Element to render when users want to create a new choice | | `createLabel` | Optional | `string` | `ra.action.create` | The label for the menu item allowing users to create a new choice. Used when the filter is empty | | `disableValue` | Optional | `string` | 'disabled' | The custom field name used in `choices` to disable some choices | @@ -40,68 +44,75 @@ import { SelectInput } from 'react-admin'; `<SelectInput>` also accepts the [common input props](./Inputs.md#common-input-props). -## Usage +## `choices` -You can customize the properties to use for the option name and value, thanks to the `optionText` and `optionValue` attributes: +An array of objects representing the choices to show in the dropdown. The objects must have at least two fields: one to use for the option name, and the other to use for the option value. By default, `<SelectInput>` will use the `id` and `name` fields. ```jsx const choices = [ - { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, - { _id: 456, full_name: 'Jane Austen', sex: 'F' }, + { id: 'programming', name: 'Programming' }, + { id: 'lifestyle', name: 'Lifestyle' }, + { id: 'photography', name: 'Photography' }, ]; -<SelectInput source="author_id" choices={choices} optionText="full_name" optionValue="_id" /> + +<SelectInput source="category" choices={choices} /> ``` -`optionText` also accepts a function, so you can shape the option text at will: +If the choices have different keys, you can use [`optionText`](#optiontext) and [`optionValue`](#optionvalue) to specify which fields to use for the name and value. ```jsx const choices = [ - { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 456, first_name: 'Jane', last_name: 'Austen' }, + { name: 'programming', label: 'Programming' }, + { name: 'lifestyle', label: 'Lifestyle' }, + { name: 'photography', label: 'Photography' }, ]; -const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; -<SelectInput source="author_id" choices={choices} optionText={optionRenderer} /> + +<SelectInput + source="category" + optionValue="name" + optionText="label" + choices={choices} +/> ``` -`optionText` also accepts a React Element, that will be cloned and receive the related choice as the `record` prop. You can use Field components there. +When used inside a `<ReferenceInput>`, `<SelectInput>` doesn't need a `choices` prop. Instead, it will use the records fetched by `<ReferenceInput>` as choices, via the `ChoicesContext`. ```jsx -const choices = [ - { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, - { id: 456, first_name: 'Jane', last_name: 'Austen' }, -]; -const FullNameField = ({ record }) => <span>{record.first_name} {record.last_name}</span>; -<SelectInput source="gender" choices={choices} optionText={<FullNameField />}/> +<ReferenceInput label="Author" source="author_id" reference="authors"> + <SelectInput /> +</ReferenceInput> ``` -An empty choice is always added (with a default `''` value, which you can overwrite with the `emptyValue` prop) on top of the options. You can furthermore customize the `MenuItem` for the empty choice by using the `emptyText` prop, which can receive either a string or a React Element, which doesn't receive any props. +See [Using in a `ReferenceInput>`](#using-in-a-referenceinput) below for more information. -```jsx -<SelectInput source="category" emptyValue={null} choices={[ - { id: 'programming', name: 'Programming' }, - { id: 'lifestyle', name: 'Lifestyle' }, - { id: 'photography', name: 'Photography' }, -]} /> -``` +## `disableValue` -The choices are translated by default, so you can use translation identifiers as choices: +You can use a custom field name by setting `disableValue` prop: ```jsx const choices = [ - { id: 'M', name: 'myroot.gender.male' }, - { id: 'F', name: 'myroot.gender.female' }, + { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, + { _id: 456, full_name: 'Jane Austen', sex: 'F' }, + { _id: 987, full_name: 'Jack Harden', sex: 'M', not_available: true }, ]; +<SelectInput source="contact_id" choices={choices} optionText="full_name" optionValue="_id" disableValue="not_available" /> ``` -However, in some cases, you may not want the choice to be translated. In that case, set the `translateChoice` prop to `false`. +## `emptyValue` + +An empty choice is always added (with a default `''` value, which you can overwrite with the `emptyValue` prop) on top of the options. You can furthermore customize the `MenuItem` for the empty choice by using the `emptyText` prop, which can receive either a string or a React Element, which doesn't receive any props. ```jsx -<SelectInput source="gender" choices={choices} translateChoice={false}/> +<SelectInput source="category" emptyValue={null} choices={[ + { id: 'programming', name: 'Programming' }, + { id: 'lifestyle', name: 'Lifestyle' }, + { id: 'photography', name: 'Photography' }, +]} /> ``` -Note that `translateChoice` is set to `false` when `<SelectInput>` is a child of `<ReferenceInput>`. +## `options` -Lastly, use the `options` attribute if you want to override any of MUI's `<SelectField>` attributes: +Use the `options` attribute if you want to override any of MUI's `<SelectField>` attributes: {% raw %} ```jsx @@ -113,17 +124,41 @@ Lastly, use the `options` attribute if you want to override any of MUI's `<Selec Refer to [MUI Select documentation](https://mui.com/api/select) for more details. -**Tip**: If you want to populate the `choices` attribute with a list of related records, you should decorate `<SelectInput>` with [`<ReferenceInput>`](./ReferenceInput.md), and leave the `choices` empty: +## `optionText` + +You can customize the properties to use for the option name and value, thanks to the `optionText` and `optionValue` attributes: ```jsx -import { SelectInput, ReferenceInput } from 'react-admin'; +const choices = [ + { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, + { _id: 456, full_name: 'Jane Austen', sex: 'F' }, +]; +<SelectInput source="author_id" choices={choices} optionText="full_name" optionValue="_id" /> +``` -<ReferenceInput label="Author" source="author_id" reference="authors"> - <SelectInput optionText="last_name" /> -</ReferenceInput> +`optionText` also accepts a function, so you can shape the option text at will: + +```jsx +const choices = [ + { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 456, first_name: 'Jane', last_name: 'Austen' }, +]; +const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; +<SelectInput source="author_id" choices={choices} optionText={optionRenderer} /> ``` -If, instead of showing choices as a dropdown list, you prefer to display them as a list of radio buttons, try the [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md). And if the list is too big, prefer the [`<AutocompleteInput>`](./AutocompleteInput.md). +`optionText` also accepts a React Element, that will be cloned and receive the related choice as the `record` prop. You can use Field components there. + +```jsx +const choices = [ + { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + { id: 456, first_name: 'Jane', last_name: 'Austen' }, +]; +const FullNameField = ({ record }) => <span>{record.first_name} {record.last_name}</span>; +<SelectInput source="gender" choices={choices} optionText={<FullNameField />}/> +``` + +## `resettable` You can make the `SelectInput` component resettable using the `resettable` prop. This will add a reset button which will be displayed only when the field has a value. @@ -140,15 +175,55 @@ const choices = [ <SelectInput source="author_id" choices={choices} optionText="full_name" optionValue="_id" /> ``` -You can use a custom field name by setting `disableValue` prop: +## `sx`: CSS API + +The `<SelectInput>` component accepts the usual `className` prop. You can also override many styles of the inner components thanks to the `sx` property (as most MUI components, see their [documentation about it](https://mui.com/customization/how-to-customize/#overriding-nested-component-styles)). This property accepts the following subclasses: + +| Rule name | Description | +|--------------------------|-----------------------------------------------------------| +| `& .RaSelectInput-input` | Applied to the underlying `ResettableTextField` component | + +To override the style of all instances of `<SelectInput>` using the [MUI style overrides](https://mui.com/customization/globals/#css), use the `RaSelectInput` key. + +## `translateChoice` + +The choices are translated by default, so you can use translation identifiers as choices: ```jsx const choices = [ - { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, - { _id: 456, full_name: 'Jane Austen', sex: 'F' }, - { _id: 987, full_name: 'Jack Harden', sex: 'M', not_available: true }, + { id: 'M', name: 'myroot.gender.male' }, + { id: 'F', name: 'myroot.gender.female' }, ]; -<SelectInput source="contact_id" choices={choices} optionText="full_name" optionValue="_id" disableValue="not_available" /> +``` + +However, in some cases, you may not want the choice to be translated. In that case, set the `translateChoice` prop to `false`. + +```jsx +<SelectInput source="gender" choices={choices} translateChoice={false}/> +``` + +Note that `translateChoice` is set to `false` when `<SelectInput>` is a child of `<ReferenceInput>`. + +## Using In A ReferenceInput + +If you want to populate the `choices` attribute with a list of related records, you should decorate `<SelectInput>` with [`<ReferenceInput>`](./ReferenceInput.md), and leave the `choices` empty: + +```jsx +import { SelectInput, ReferenceInput } from 'react-admin'; + +<ReferenceInput label="Author" source="author_id" reference="authors"> + <SelectInput /> +</ReferenceInput> +``` + +In that case, `<SelectInput>` uses the [`recordRepresentation`](./Resource.md#recordrepresentation) to render each choice from the list of possible records. You can oferride this behavior by setting the `optionText` prop: + +```jsx +import { SelectInput, ReferenceInput } from 'react-admin'; + +<ReferenceInput label="Author" source="author_id" reference="authors"> + <SelectInput optionText="last_name" /> +</ReferenceInput> ``` ## Creating New Choices @@ -267,14 +342,4 @@ const CreateCategory = () => { ); }; ``` -{% endraw %} - -## `sx`: CSS API - -The `<SelectInput>` component accepts the usual `className` prop. You can also override many styles of the inner components thanks to the `sx` property (as most MUI components, see their [documentation about it](https://mui.com/customization/how-to-customize/#overriding-nested-component-styles)). This property accepts the following subclasses: - -| Rule name | Description | -|--------------------------|-----------------------------------------------------------| -| `& .RaSelectInput-input` | Applied to the underlying `ResettableTextField` component | - -To override the style of all instances of `<SelectInput>` using the [MUI style overrides](https://mui.com/customization/globals/#css), use the `RaSelectInput` key. +{% endraw %} \ No newline at end of file diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 07d9d7ee209..2682cfb6196 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -158,6 +158,7 @@ export const useReferenceArrayInputController = < ? params.page * params.perPage < total : undefined, hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : params.page > 1, + isFromReference: true, }; }; diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 27af4b35bb6..87c1d6cf866 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -169,6 +169,7 @@ export const useReferenceInputController = <RecordType extends RaRecord = any>( ? params.page * params.perPage < total : undefined, hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : params.page > 1, + isFromReference: true, }; }; diff --git a/packages/ra-core/src/form/choices/ChoicesContext.ts b/packages/ra-core/src/form/choices/ChoicesContext.ts index 3421d93bba5..d7e5fe65481 100644 --- a/packages/ra-core/src/form/choices/ChoicesContext.ts +++ b/packages/ra-core/src/form/choices/ChoicesContext.ts @@ -39,4 +39,5 @@ export type ChoicesContextValue<RecordType extends RaRecord = any> = { sort: SortPayload; source: string; total: number; + isFromReference: boolean; }; diff --git a/packages/ra-core/src/form/choices/useChoicesContext.ts b/packages/ra-core/src/form/choices/useChoicesContext.ts index e4fefe4a143..f6aef2743cf 100644 --- a/packages/ra-core/src/form/choices/useChoicesContext.ts +++ b/packages/ra-core/src/form/choices/useChoicesContext.ts @@ -44,6 +44,7 @@ export const useChoicesContext = <ChoicesType extends RaRecord = RaRecord>( sort: options.sort ?? list.sort, source: options.source, total: options.total ?? list.total, + isFromReference: false, }; } return context; diff --git a/packages/ra-core/src/form/useChoices.tsx b/packages/ra-core/src/form/useChoices.tsx index 205d655b458..16779ded22d 100644 --- a/packages/ra-core/src/form/useChoices.tsx +++ b/packages/ra-core/src/form/useChoices.tsx @@ -9,7 +9,7 @@ import { RecordContextProvider } from '../controller'; export type OptionTextElement = ReactElement<{ record: RaRecord; }>; -export type OptionTextFunc = (choice: any) => string | OptionTextElement; +export type OptionTextFunc = (choice: any) => React.ReactNode; export type OptionText = OptionTextElement | OptionTextFunc | string; export interface ChoicesProps { diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index 667ee8b97b5..7c459bfbcf2 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -48,11 +48,21 @@ const Wrapper = ({ children }) => ( ); const authors = [ - { id: 1, name: 'Leo Tolstoy', language: 'Russian' }, - { id: 2, name: 'Victor Hugo', language: 'French' }, - { id: 3, name: 'William Shakespeare', language: 'English' }, - { id: 4, name: 'Charles Baudelaire', language: 'French' }, - { id: 5, name: 'Marcel Proust', language: 'French' }, + { id: 1, first_name: 'Leo', last_name: 'Tolstoy', language: 'Russian' }, + { id: 2, first_name: 'Victor', last_name: 'Hugo', language: 'French' }, + { + id: 3, + first_name: 'William', + last_name: 'Shakespeare', + language: 'English', + }, + { + id: 4, + first_name: 'Charles', + last_name: 'Baudelaire', + language: 'French', + }, + { id: 5, first_name: 'Marcel', last_name: 'Proust', language: 'French' }, ]; const dataProviderWithAuthors = { @@ -74,38 +84,22 @@ const dataProviderWithAuthors = { getList: (resource, params) => new Promise(resolve => { // eslint-disable-next-line eqeqeq - if (params.filter.q == undefined) { - setTimeout( - () => - resolve({ - data: authors, - total: authors.length, - }), - 500 - ); - return; - } - - const filteredAuthors = authors.filter(author => - author.name - .toLowerCase() - .includes(params.filter.q.toLowerCase()) - ); - setTimeout( () => resolve({ - data: filteredAuthors, - total: filteredAuthors.length, + data: authors, + total: authors.length, }), 500 ); + return; }), update: (resource, params) => Promise.resolve(params), create: (resource, params) => { const newAuthor = { id: authors.length + 1, - name: params.data.name, + first_name: params.data.first_name, + last_name: params.data.last_name, language: params.data.language, }; authors.push(newAuthor); @@ -134,7 +128,12 @@ const history = createMemoryHistory({ initialEntries: ['/books/1'] }); export const InsideReferenceInput = () => ( <Admin dataProvider={dataProviderWithAuthors} history={history}> - <Resource name="authors" /> + <Resource + name="authors" + recordRepresentation={record => + `${record.first_name} ${record.last_name}` + } + /> <Resource name="books" edit={BookEditWithReference} /> </Admin> ); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 37f000f92b6..50f5421b77f 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -12,6 +12,7 @@ import { ChoicesProps, useChoices, RaRecord, + useGetRecordRepresentation, } from 'ra-core'; import { CommonInputProps } from './CommonInputProps'; @@ -135,7 +136,13 @@ export const SelectInput = (props: SelectInputProps) => { ...rest } = props; const translate = useTranslate(); - const { allChoices, isLoading, source, resource } = useChoicesContext({ + const { + allChoices, + isLoading, + source, + resource, + isFromReference, + } = useChoicesContext({ choices: choicesProp, isLoading: isLoadingProp, isFetching: isFetchingProp, @@ -143,8 +150,11 @@ export const SelectInput = (props: SelectInputProps) => { source: sourceProp, }); + const getRecordRepresentation = useGetRecordRepresentation(resource); const { getChoiceText, getChoiceValue, getDisableValue } = useChoices({ - optionText, + optionText: + optionText ?? + (isFromReference ? getRecordRepresentation : undefined), optionValue, disableValue, translateChoice, @@ -318,8 +328,8 @@ SelectInput.propTypes = { PropTypes.string, PropTypes.func, PropTypes.element, - ]).isRequired, - optionValue: PropTypes.string.isRequired, + ]), + optionValue: PropTypes.string, disableValue: PropTypes.string, resettable: PropTypes.bool, resource: PropTypes.string, @@ -331,8 +341,6 @@ SelectInput.defaultProps = { emptyText: '', emptyValue: '', options: {}, - optionText: 'name', - optionValue: 'id', translateChoice: true, disableValue: 'disabled', }; From 582d2998dfabde8aad866b339bf869983ce6db1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 22:06:06 +0200 Subject: [PATCH 10/23] Fix guesser tests --- .../src/inference/inferElementFromValues.tsx | 24 ++++++++++++------- .../src/detail/ShowGuesser.spec.tsx | 2 +- .../src/list/ListGuesser.spec.tsx | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/ra-core/src/inference/inferElementFromValues.tsx b/packages/ra-core/src/inference/inferElementFromValues.tsx index 0f0ee740e49..18f0de37b06 100644 --- a/packages/ra-core/src/inference/inferElementFromValues.tsx +++ b/packages/ra-core/src/inference/inferElementFromValues.tsx @@ -89,20 +89,28 @@ const inferElementFromValues = ( const reference = inflection.pluralize(name.substr(0, name.length - 3)); return ( types.reference && - new InferredElement(types.reference, { - source: name, - reference, - }) + new InferredElement( + types.reference, + { + source: name, + reference, + }, + new InferredElement(types.referenceChild) + ) ); } if (name.substr(name.length - 2) === 'Id' && hasType('reference', types)) { const reference = inflection.pluralize(name.substr(0, name.length - 2)); return ( types.reference && - new InferredElement(types.reference, { - source: name, - reference, - }) + new InferredElement( + types.reference, + { + source: name, + reference, + }, + new InferredElement(types.referenceChild) + ) ); } if ( diff --git a/packages/ra-ui-materialui/src/detail/ShowGuesser.spec.tsx b/packages/ra-ui-materialui/src/detail/ShowGuesser.spec.tsx index e23492afdee..e8e806264d7 100644 --- a/packages/ra-ui-materialui/src/detail/ShowGuesser.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowGuesser.spec.tsx @@ -42,7 +42,7 @@ export const CommentShow = () => ( <SimpleShowLayout> <TextField source="id" /> <TextField source="author" /> - <ReferenceField source="post_id" reference="posts"><TextField source="id" /></ReferenceField> + <ReferenceField source="post_id" reference="posts" /> <NumberField source="score" /> <TextField source="body" /> <DateField source="created_at" /> diff --git a/packages/ra-ui-materialui/src/list/ListGuesser.spec.tsx b/packages/ra-ui-materialui/src/list/ListGuesser.spec.tsx index 03fc6669e02..3b991216b55 100644 --- a/packages/ra-ui-materialui/src/list/ListGuesser.spec.tsx +++ b/packages/ra-ui-materialui/src/list/ListGuesser.spec.tsx @@ -45,7 +45,7 @@ export const CommentList = () => ( <Datagrid rowClick="edit"> <TextField source="id" /> <TextField source="author" /> - <ReferenceField source="post_id" reference="posts"><TextField source="id" /></ReferenceField> + <ReferenceField source="post_id" reference="posts" /> <NumberField source="score" /> <TextField source="body" /> <DateField source="created_at" /> From 9d5de586884798d0538d839f4386807a9a8ed3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 22:29:31 +0200 Subject: [PATCH 11/23] Add support for recordRepresentaiton in ReferenceInput --- docs/AutocompleteInput.md | 41 +++--- .../src/input/AutocompleteInput.stories.tsx | 121 +++++++++++++++++- .../src/input/AutocompleteInput.tsx | 13 +- .../src/input/SelectInput.stories.tsx | 2 +- 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/docs/AutocompleteInput.md b/docs/AutocompleteInput.md index debc011c33c..3bc6250c7a1 100644 --- a/docs/AutocompleteInput.md +++ b/docs/AutocompleteInput.md @@ -22,11 +22,23 @@ import { AutocompleteInput } from 'react-admin'; ]} /> ``` +**Tip**: If you want to populate the `choices` attribute with a list of related records, you should decorate `<AutocompleteInput>` with [`<ReferenceInput>`](./ReferenceInput.md), and leave the `choices` empty: + +```jsx +import { AutocompleteInput, ReferenceInput } from 'react-admin'; + +<ReferenceInput label="Post" source="post_id" reference="posts"> + <AutocompleteInput /> +</ReferenceInput> +``` + +**Tip**: `<AutocompleteInput>` is a stateless component, so it only allows to *filter* the list of choices, not to *extend* it. If you need to populate the list of choices based on the result from a `fetch` call (and if [`<ReferenceInput>`](./ReferenceInput.md) doesn't cover your need), you'll have to [write your own Input component](./Inputs.md#writing-your-own-input-component) based on MUI `<AutoComplete>` component. + ## Properties | Prop | Required | Type | Default | Description | |---------------------------|----------|-----------------------------------------------|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `choices` | Required | `Object[]` | `-` | List of items to autosuggest | +| `choices` | Optional | `Object[]` | `-` | List of items to autosuggest. Required if not inside a ReferenceInput. | | `create` | Optional | `Element` | `-` | A React Element to render when users want to create a new choice | | `createItemLabel` | Optional | `string` | `ra.action.create_item` | The label for the menu item allowing users to create a new choice. Used when the filter is not empty | | `emptyValue` | Optional | `any` | `''` | The value to use for the empty element | @@ -43,7 +55,7 @@ import { AutocompleteInput } from 'react-admin'; `<AutocompleteInput>` also accepts the [common input props](./Inputs.md#common-input-props). -## Usage +## `optionText` You can customize the properties to use for the option name and value, thanks to the `optionText` and `optionValue` attributes: @@ -96,6 +108,8 @@ const matchSuggestion = (filter, choice) => { /> ``` +## `translateChoice` + The choices are translated by default, so you can use translation identifiers as choices: ```jsx @@ -112,9 +126,17 @@ In that case, set the `translateChoice` prop to `false`. <AutocompleteInput source="gender" choices={choices} translateChoice={false}/> ``` +## `shouldRenderSuggestions` + When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain usable performance. The `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly, and might be all you need (depending on your data set). Ex. `<AutocompleteInput shouldRenderSuggestions={(val) => { return val.trim().length > 2 }} />` would not render any suggestions until the 3rd character has been entered. This prop is passed to the underlying `react-autosuggest` component and is documented [here](https://github.com/moroshko/react-autosuggest#should-render-suggestions-prop). +## `sx`: CSS API + +This component doesn't apply any custom styles on top of [MUI `<Autocomplete>` component](https://mui.com/components/autocomplete/). Refer to their documentation to know its CSS API. + +## Additonal Props + `<AutocompleteInput>` renders a [MUI `<Autocomplete>` component](https://mui.com/components/autocomplete/) and it accepts the `<Autocomplete>` props: {% raw %} @@ -123,18 +145,6 @@ Ex. `<AutocompleteInput shouldRenderSuggestions={(val) => { return val.trim().le ``` {% endraw %} -**Tip**: If you want to populate the `choices` attribute with a list of related records, you should decorate `<AutocompleteInput>` with [`<ReferenceInput>`](./ReferenceInput.md), and leave the `choices` empty: - -```jsx -import { AutocompleteInput, ReferenceInput } from 'react-admin'; - -<ReferenceInput label="Post" source="post_id" reference="posts"> - <AutocompleteInput optionText="title" /> -</ReferenceInput> -``` - -**Tip**: `<AutocompleteInput>` is a stateless component, so it only allows to *filter* the list of choices, not to *extend* it. If you need to populate the list of choices based on the result from a `fetch` call (and if [`<ReferenceInput>`](./ReferenceInput.md) doesn't cover your need), you'll have to [write your own Input component](./Inputs.md#writing-your-own-input-component) based on MUI `<AutoComplete>` component. - ## Creating New Choices The `<AutocompleteInput>` can allow users to create a new choice if either the `create` or `onCreate` prop is provided. @@ -288,6 +298,3 @@ const PostCreate = () => { ``` {% endraw %} -## `sx`: CSS API - -This component doesn't apply any custom styles on top of [MUI `<Autocomplete>` component](https://mui.com/components/autocomplete/). Refer to their documentation to know its CSS API. diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index 4437474fecf..4767ce90ef8 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -250,6 +250,119 @@ export const CreationSupport = () => ( </Admin> ); +const authorsWithFirstAndLastName = [ + { id: 1, first_name: 'Leo', last_name: 'Tolstoy', language: 'Russian' }, + { id: 2, first_name: 'Victor', last_name: 'Hugo', language: 'French' }, + { + id: 3, + first_name: 'William', + last_name: 'Shakespeare', + language: 'English', + }, + { + id: 4, + first_name: 'Charles', + last_name: 'Baudelaire', + language: 'French', + }, + { id: 5, first_name: 'Marcel', last_name: 'Proust', language: 'French' }, +]; + +const dataProviderWithAuthorsWithFirstAndLastName = { + getOne: (resource, params) => + Promise.resolve({ + data: { + id: 1, + title: 'War and Peace', + author: 1, + summary: + "War and Peace broadly focuses on Napoleon's invasion of Russia, and the impact it had on Tsarist society. The book explores themes such as revolution, revolution and empire, the growth and decline of various states and the impact it had on their economies, culture, and society.", + year: 1869, + }, + }), + getMany: (resource, params) => + Promise.resolve({ + data: authorsWithFirstAndLastName.filter(author => + params.ids.includes(author.id) + ), + }), + getList: (resource, params) => + new Promise(resolve => { + // eslint-disable-next-line eqeqeq + if (params.filter.q == undefined) { + setTimeout( + () => + resolve({ + data: authorsWithFirstAndLastName, + total: authors.length, + }), + 500 + ); + return; + } + + const filteredAuthors = authorsWithFirstAndLastName.filter(author => + author.last_name + .toLowerCase() + .includes(params.filter.q.toLowerCase()) + ); + + setTimeout( + () => + resolve({ + data: filteredAuthors, + total: filteredAuthors.length, + }), + 500 + ); + }), + update: (resource, params) => Promise.resolve(params), + create: (resource, params) => { + const newAuthor = { + id: authorsWithFirstAndLastName.length + 1, + name: params.data.name, + language: params.data.language, + }; + authors.push(newAuthor); + return Promise.resolve({ data: newAuthor }); + }, +} as any; + +const BookEditWithReferenceAndRecordRepresentation = () => ( + <Edit + mutationMode="pessimistic" + mutationOptions={{ + onSuccess: data => { + console.log(data); + }, + }} + > + <SimpleForm> + <ReferenceInput reference="authors" source="author"> + <AutocompleteInput /> + </ReferenceInput> + </SimpleForm> + </Edit> +); + +export const InsideReferenceInputWithRecordRepresentation = () => ( + <Admin + dataProvider={dataProviderWithAuthorsWithFirstAndLastName} + history={history} + > + <Resource + name="authors" + recordRepresentation={record => + `${record.first_name} ${record.last_name}` + } + /> + <Resource + name="books" + edit={BookEditWithReferenceAndRecordRepresentation} + /> + </Admin> +); + const authors = [ { id: 1, name: 'Leo Tolstoy', language: 'Russian' }, { id: 2, name: 'Victor Hugo', language: 'French' }, @@ -327,7 +440,7 @@ const BookEditWithReference = () => ( > <SimpleForm> <ReferenceInput reference="authors" source="author"> - <AutocompleteInput fullWidth /> + <AutocompleteInput fullWidth optionText="name" /> </ReferenceInput> </SimpleForm> </Edit> @@ -407,7 +520,11 @@ const BookEditWithReferenceAndCreationSupport = () => ( > <SimpleForm> <ReferenceInput reference="authors" source="author"> - <AutocompleteInput create={<CreateAuthor />} fullWidth /> + <AutocompleteInput + create={<CreateAuthor />} + optionText="name" + fullWidth + /> </ReferenceInput> </SimpleForm> </Edit> diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 0b9f3eadd0c..1f7e322e1d0 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -6,6 +6,7 @@ import { useMemo, useRef, useState, + ReactNode, } from 'react'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; @@ -30,6 +31,7 @@ import { useTimeout, useTranslate, warning, + useGetRecordRepresentation, } from 'ra-core'; import { SupportCreateSuggestionOptions, @@ -153,8 +155,8 @@ export const AutocompleteInput = < onChange, onCreate, openText = 'ra.action.open', - optionText = 'name', - optionValue = 'id', + optionText, + optionValue, parse, resource: resourceProp, shouldRenderSuggestions, @@ -175,6 +177,7 @@ export const AutocompleteInput = < resource, source, setFilters, + isFromReference, } = useChoicesContext({ choices: choicesProp, isFetching: isFetchingProp, @@ -240,13 +243,16 @@ If you provided a React element for the optionText prop, you must also provide t /* eslint-enable eqeqeq */ }, [shouldRenderSuggestions, noOptionsText]); + const getRecordRepresentation = useGetRecordRepresentation(resource); const { getChoiceText, getChoiceValue, getSuggestions } = useSuggestions({ choices: allChoices, emptyText, emptyValue, limitChoicesToValue, matchSuggestion, - optionText, + optionText: + optionText ?? + (isFromReference ? getRecordRepresentation : undefined), optionValue, selectedItem: selectedChoice, suggestionLimit, @@ -576,6 +582,7 @@ export interface AutocompleteInputProps< >, 'onChange' | 'options' | 'renderInput' > { + children?: ReactNode; debounce?: number; filterToQuery?: (searchText: string) => any; inputText?: (option: any) => string; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index 7c459bfbcf2..658e0123ec0 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -118,7 +118,7 @@ const BookEditWithReference = () => ( > <SimpleForm> <ReferenceInput reference="authors" source="author"> - <SelectInput fullWidth /> + <SelectInput /> </ReferenceInput> </SimpleForm> </Edit> From 58ccca6aeb74a397ed427b04294388e37a704fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 22:53:04 +0200 Subject: [PATCH 12/23] Let ReferenceInput render without child --- .../src/input/ReferenceInput.stories.tsx | 85 ++++++++++++++++++- .../src/input/ReferenceInput.tsx | 41 ++++----- 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx index e455101a500..2c8f0a65da5 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx @@ -1,15 +1,96 @@ import * as React from 'react'; -import { Form, testDataProvider } from 'ra-core'; +import { createMemoryHistory } from 'history'; +import { Admin, AdminContext } from 'react-admin'; +import { Resource, Form, testDataProvider } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import { Stack, Divider, Typography } from '@mui/material'; -import { AdminContext } from '../AdminContext'; +import { Edit } from '../detail'; +import { SimpleForm } from '../form'; import { SelectInput, TextInput } from '../input'; import { ReferenceInput } from './ReferenceInput'; export default { title: 'ra-ui-materialui/input/ReferenceInput' }; +const authors = [ + { id: 1, first_name: 'Leo', last_name: 'Tolstoy', language: 'Russian' }, + { id: 2, first_name: 'Victor', last_name: 'Hugo', language: 'French' }, + { + id: 3, + first_name: 'William', + last_name: 'Shakespeare', + language: 'English', + }, + { + id: 4, + first_name: 'Charles', + last_name: 'Baudelaire', + language: 'French', + }, + { id: 5, first_name: 'Marcel', last_name: 'Proust', language: 'French' }, +]; + +const dataProviderWithAuthors = { + getOne: (resource, params) => + Promise.resolve({ + data: { + id: 1, + title: 'War and Peace', + author: 1, + summary: + "War and Peace broadly focuses on Napoleon's invasion of Russia, and the impact it had on Tsarist society. The book explores themes such as revolution, revolution and empire, the growth and decline of various states and the impact it had on their economies, culture, and society.", + year: 1869, + }, + }), + getMany: (resource, params) => + Promise.resolve({ + data: authors.filter(author => params.ids.includes(author.id)), + }), + getList: (resource, params) => + new Promise(resolve => { + // eslint-disable-next-line eqeqeq + setTimeout( + () => + resolve({ + data: authors, + total: authors.length, + }), + 500 + ); + return; + }), +} as any; + +const BookEdit = () => ( + <Edit + mutationMode="pessimistic" + mutationOptions={{ + onSuccess: data => { + console.log(data); + }, + }} + > + <SimpleForm> + <ReferenceInput reference="authors" source="author" /> + </SimpleForm> + </Edit> +); + +const history = createMemoryHistory({ initialEntries: ['/books/1'] }); + +export const Basic = () => ( + <Admin dataProvider={dataProviderWithAuthors} history={history}> + <Resource + name="authors" + recordRepresentation={record => + `${record.first_name} ${record.last_name}` + } + /> + <Resource name="books" edit={BookEdit} /> + </Admin> +); + const tags = [ { id: 5, name: 'lorem' }, { id: 6, name: 'ipsum' }, diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index 22feec74228..ce0632eaee2 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -8,30 +8,29 @@ import { } from 'ra-core'; import { ReferenceError } from './ReferenceError'; +import { AutocompleteInput } from './AutocompleteInput'; /** * An Input component for choosing a reference record. Useful for foreign keys. * * This component fetches the possible values in the reference resource - * (using `dataProvider.getList()`), then delegates rendering - * to a subcomponent, to which it passes the possible choices - * as the `choices` attribute. + * (using `dataProvider.getList()`), then renders an `<AutocompleteInput>`, + * to which it passes the possible choices via a `ChoicesContext`. * - * Use it with a selector component as child, like `<AutocompleteInput>`, - * `<SelectInput>`, or `<RadioButtonGroupInput>`. + * You can pas a child select component to customize the way the reference + * selector is displayed (e.g. using `<SelectInput>` or `<RadioButtonGroupInput>` + * instead of `<AutocompleteInput>`). * - * @example + * @example // default selector: AutocompleteInput * export const CommentEdit = (props) => ( * <Edit {...props}> * <SimpleForm> - * <ReferenceInput label="Post" source="post_id" reference="posts"> - * <AutocompleteInput optionText="title" /> - * </ReferenceInput> + * <ReferenceInput label="Post" source="post_id" reference="posts" /> * </SimpleForm> * </Edit> * ); * - * @example + * @example // using a SelectInput as selector * export const CommentEdit = (props) => ( * <Edit {...props}> * <SimpleForm> @@ -46,12 +45,7 @@ import { ReferenceError } from './ReferenceError'; * by setting the `perPage` prop. * * @example - * <ReferenceInput - * source="post_id" - * reference="posts" - * perPage={100}> - * <SelectInput optionText="title" /> - * </ReferenceInput> + * <ReferenceInput source="post_id" reference="posts" perPage={100}/> * * 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). @@ -60,9 +54,8 @@ import { ReferenceError } from './ReferenceError'; * <ReferenceInput * source="post_id" * reference="posts" - * sort={{ field: 'title', order: 'ASC' }}> - * <SelectInput optionText="title" /> - * </ReferenceInput> + * sort={{ field: 'title', order: 'ASC' }} + * /> * * Also, you can filter the query used to populate the possible values. Use the * `filter` prop for that. @@ -71,9 +64,8 @@ import { ReferenceError } from './ReferenceError'; * <ReferenceInput * source="post_id" * reference="posts" - * filter={{ is_published: true }}> - * <SelectInput optionText="title" /> - * </ReferenceInput> + * filter={{ is_published: true }} + * /> * * The enclosed component may filter results. ReferenceInput create a ChoicesContext which provides * a `setFilters` function. You can call this function to filter the results. @@ -103,7 +95,7 @@ export const ReferenceInput = (props: ReferenceInputProps) => { }; ReferenceInput.propTypes = { - children: PropTypes.element.isRequired, + children: PropTypes.element, filter: PropTypes.object, label: PropTypes.string, page: PropTypes.number, @@ -123,10 +115,11 @@ ReferenceInput.defaultProps = { page: 1, perPage: 25, sort: { field: 'id', order: 'DESC' }, + children: <AutocompleteInput />, }; export interface ReferenceInputProps extends InputProps { - children: ReactElement; + children?: ReactElement; label?: string; page?: number; perPage?: number; From cacdbaefc200d72fe793b5b62801dedfa4128736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Tue, 26 Jul 2022 23:35:46 +0200 Subject: [PATCH 13/23] Make ReferenceInput capable of rendering without a child --- docs/ReferenceInput.md | 161 ++++++++++++------ docs/SelectInput.md | 2 +- docs/Tutorial.md | 29 +--- examples/simple/src/comments/CommentList.tsx | 5 +- examples/simple/src/comments/index.tsx | 3 +- .../src/detail/EditGuesser.spec.tsx | 4 +- .../src/detail/editFieldTypes.tsx | 10 +- .../src/input/ReferenceInput.tsx | 2 +- 8 files changed, 129 insertions(+), 87 deletions(-) diff --git a/docs/ReferenceInput.md b/docs/ReferenceInput.md index 74eb3972636..5896980b78d 100644 --- a/docs/ReferenceInput.md +++ b/docs/ReferenceInput.md @@ -5,83 +5,148 @@ title: "The ReferenceInput Component" # `<ReferenceInput>` -Use `<ReferenceInput>` 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). It delegates the rendering to its child component by providing the possible choices through the `ChoicesContext`. This context value can be accessed with the [`useChoicesContext`](./useChoicesContext.md) hook. +Use `<ReferenceInput>` for foreign-key values, for instance, to edit the `post_id` of a `comment` resource. -This means you can use `<ReferenceInput>` with any of [`<SelectInput>`](./SelectInput.md), [`<AutocompleteInput>`](./AutocompleteInput.md), or [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md), or even with the component of your choice, provided they detect a `ChoicesContext` is available and get their choices from it. +![ReferenceInput](./img/reference-input.gif) + +## Usage The component expects a `source` and a `reference` attributes. For instance, to make the `post_id` for a `comment` editable: +```jsx +import { ReferenceInput } from 'react-admin'; + +<ReferenceInput source="post_id" reference="posts" /> +``` + +This component fetches the related record (using `dataProvider.getMany()`) as well as possible choices (using `dataProvider.getList()` in the reference resource). + +`<ReferenceInput>` renders an [`<AutocompleteInput>`](./AutocompleteInput.md) to let the user select the related record. + +You can tweak how this component fetches the possible values using the `page`, `perPage`, `sort`, and `filter` props. + +## Props + +| Prop | Required | Type | Default | Description | +|--------------------|----------|---------------------------------------------|----------------------------------|------------------------------------------------------------------------------------------| +| `source` | Required | `string` | - | Name of the entity property to use for the input value | +| `reference` | Required | `string` | '' | Name of the reference resource, e.g. 'posts'. | +| `children` | Optional | `ReactNode` | `<AutocompleteInput />` | The actual selection component | +| `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | +| `page` | Optional | `number` | 1 | The current page number | +| `perPage` | Optional | `number` | 25 | Number of suggestions to show | +| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | How to order the list of suggestions | +| `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | + +**Note**: `<ReferenceInput>` doesn't accept the [common input props](./Inputs.md#common-input-props) (like `label`) ; it is the responsability of the child component to apply them. + +## `children` + +By default, `<ReferenceInput>` renders an [`<AutocompleteInput>`](./AutocompleteInput.md) to let end users select the reference record. + +You can pass a child component to customize the way the reference selector is displayed. + +For instance, to customize the input label, set the `label` prop on the child component: + +```jsx +import { ReferenceInput, AutocompleteInput } from 'react-admin'; + +<ReferenceInput source="post_id" reference="posts"> + <AutocompleteInput label="Post" /> +</ReferenceInput> +``` + +You can also use [`<SelectInput>`](./SelectInput.md) or [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md) instead of [`<AutocompleteInput>`](./AutocompleteInput.md). + ```jsx import { ReferenceInput, SelectInput } from 'react-admin'; <ReferenceInput source="post_id" reference="posts"> - <SelectInput label="Post" optionText="title" /> + <SelectInput /> </ReferenceInput> ``` -![ReferenceInput](./img/reference-input.gif) +You can even use a component of your own as child, provided its detects a `ChoicesContext` is available and gets their choices from it. -## Properties +The choices context value can be accessed with the [`useChoicesContext`](./useChoicesContext.md) hook. -| Prop | Required | Type | Default | Description | -|--------------------|----------|---------------------------------------------|----------------------------------|-------------------------------------------------------------------------------------------------------------------| -| `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | -| `page` | Optional | `number` | 1 | The current page number | -| `perPage` | Optional | `number` | 25 | Number of suggestions to show | -| `reference` | Required | `string` | '' | Name of the reference resource, e.g. 'posts'. | -| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | How to order the list of suggestions | -| `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | +## `enableGetChoices` +You can make the `getList()` call lazy by using the `enableGetChoices` prop. This prop should be a function that receives the `filterValues` as parameter and return a boolean. This can be useful when using an `AutocompleteInput` on a resource with a lot of data. The following example only starts fetching the options when the query has at least 2 characters: -**Note**: `<ReferenceInput>` doesn't accept the [common input props](./Inputs.md#common-input-props) ; it is the responsability of children to apply them. +```jsx +<ReferenceInput + source="post_id" + reference="posts" + enableGetChoices={({ q }) => q.length >= 2} /> +``` -## Usage +## `filter` -You can tweak how this component fetches the possible values using the `page`, `perPage`, `sort`, and `filter` props. +You can filter the query used to populate the possible values. Use the `filter` prop for that. {% raw %} ```jsx -// by default, fetches only the first 25 values. You can extend this limit -// by setting the `perPage` prop. -<ReferenceInput - source="post_id" - reference="posts" - perPage={100} -> - <SelectInput optionText="title" /> -</ReferenceInput> +<ReferenceInput source="post_id" reference="posts" filter={{ is_published: true }} /> +``` +{% endraw %} -// 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). -<ReferenceInput - source="post_id" - reference="posts" - sort={{ field: 'title', order: 'ASC' }} -> - <SelectInput optionText="title" /> -</ReferenceInput> +## `perPage` + +By default, `<ReferenceInput>` fetches only the first 25 values. You can extend this limit by setting the `perPage` prop. + +```jsx +<ReferenceInput source="post_id" reference="posts" perPage={100} /> +``` + +## `reference` + +The name of the reference resource. For instance, in a Post form, if you wanrt to edit the post author, the reference should be "authors". + +```jsx +<ReferenceInput source="author_id" reference="authors" /> +``` + +`<ReferenceInput>` will use the reference resource `recordRepresentation` to display the selected record and the list of possible records. -// you can filter the query used to populate the possible values. Use the -// `filter` prop for that. +## `sort` + +By default, `<ReferenceInput>` orders the possible values by `id` desc. You can change this order by setting the `sort` prop (an object with `field` and `order` properties). + +{% raw %} +```jsx <ReferenceInput source="post_id" reference="posts" - filter={{ is_published: true }} -> - <SelectInput optionText="title" /> -</ReferenceInput> + sort={{ field: 'title', order: 'ASC' }} +/> ``` {% endraw %} -**Tip** You can make the `getList()` call lazy by using the `enableGetChoices` prop. This prop should be a function that receives the `filterValues` as parameter and return a boolean. This can be useful when using an `AutocompleteInput` on a resource with a lot of data. The following example only starts fetching the options when the query has at least 2 characters: +## `source` + +The name of the property in the record that contains the identifier of the selected record. + +For instance, if a Post contains a reference to an author via an `author_id` property: + +```json +{ + "id": 456, + "title": "Hello world", + "author_id": 12 +} +``` + +Then to display a selector for the post author, you should call `<ReferenceInput>` as follows: ```jsx -<ReferenceInput - source="post_id" - reference="posts" - enableGetChoices={({ q }) => q.length >= 2}> - <AutocompleteInput optionText="title" /> -</ReferenceInput> +<ReferenceInput source="author_id" reference="authors" /> ``` -**Tip**: Why does `<ReferenceInput>` use the `dataProvider.getMany()` method with a single value `[id]` instead of `dataProvider.getOne()` to fetch the record for the current value? Because when there are many `<ReferenceInput>` for the same resource in a form (for instance when inside an `<ArrayInput>`), react-admin *aggregates* the calls to `dataProvider.getMany()` into a single one with `[id1, id2, ...]`. This speeds up the UI and avoids hitting the API too much. +## Performance + +Why does `<ReferenceInput>` use the `dataProvider.getMany()` method with a single value `[id]` instead of `dataProvider.getOne()` to fetch the record for the current value? + +Because when there may be many `<ReferenceInput>` for the same resource in a form (for instance when inside an `<ArrayInput>`), so react-admin *aggregates* the calls to `dataProvider.getMany()` into a single one with `[id1, id2, ...]`. + +This speeds up the UI and avoids hitting the API too much. diff --git a/docs/SelectInput.md b/docs/SelectInput.md index 19a85881871..f70bbed82d7 100644 --- a/docs/SelectInput.md +++ b/docs/SelectInput.md @@ -25,7 +25,7 @@ import { SelectInput } from 'react-admin'; If, instead of showing choices as a dropdown list, you prefer to display them as a list of radio buttons, try the [`<RadioButtonGroupInput>`](./RadioButtonGroupInput.md). And if the list is too big, prefer the [`<AutocompleteInput>`](./AutocompleteInput.md). -## Properties +## Props | Prop | Required | Type | Default | Description | |-------------------|----------|----------------------------|--------------------|----------------------------------------------------------------------------------------------------------------------------------------| diff --git a/docs/Tutorial.md b/docs/Tutorial.md index 60db5ed3c26..5e1521eb95f 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -454,7 +454,7 @@ const App = () => ( [![Post Edit Guesser](./img/tutorial_edit_guesser.gif)](./img/tutorial_edit_guesser.gif) -Users can display the edit page just by clicking on the Edit button. The form is already functional; it issues `PUT` requests to the REST API upon submission. +Users can display the edit page just by clicking on the Edit button. The form is already functional; it issues `PUT` requests to the REST API upon submission. And thanks to the `recordRepresentation` of the "users" Resource, the user name is displayed foir the post author. Copy the `<PostEdit>` code dumped by the guesser in the console to the `posts.js` file so that you can customize the view. Don't forget to `import` the new components from react-admin: @@ -470,7 +470,6 @@ import { Edit, SimpleForm, ReferenceInput, - SelectInput, TextInput, } from 'react-admin'; @@ -481,9 +480,7 @@ export const PostList = props => ( export const PostEdit = () => ( <Edit> <SimpleForm> - <ReferenceInput source="userId" reference="users"> - <SelectInput optionText="id" /> - </ReferenceInput> + <ReferenceInput source="userId" reference="users" /> <TextInput source="id" /> <TextInput source="title" /> <TextInput source="body" /> @@ -492,7 +489,7 @@ export const PostEdit = () => ( ); ``` -You can now adjust the `<PostEdit>` component to disable the edition of the primary key (`id`), place it first, use the user `name` instead of the user `id` in the reference, and use a longer text input for the `body` field, as follows: +You can now adjust the `<PostEdit>` component to disable the edition of the primary key (`id`), place it first, and use a longer text input for the `body` field, as follows: ```diff // in src/posts.js @@ -500,10 +497,7 @@ export const PostEdit = () => ( <Edit> <SimpleForm> + <TextInput disabled source="id" /> - <ReferenceInput source="userId" reference="users"> -- <SelectInput optionText="id" /> -+ <SelectInput optionText="name" /> - </ReferenceInput> + <ReferenceInput source="userId" reference="users" /> - <TextInput source="id" /> <TextInput source="title" /> - <TextInput source="body" /> @@ -513,9 +507,9 @@ export const PostEdit = () => ( ); ``` -If you've understood the `<List>` component, the `<Edit>` component will be no surprise. It's responsible for fetching the record, and displaying the page title. It passes the record down to the `<SimpleForm>` component, which is responsible for the form layout, default values, and validation. Just like `<Datagrid>`, `<SimpleForm>` uses its children to determine the form inputs to display. It expects *input components* as children. `<TextInput>`, `<ReferenceInput>`, and `<SelectInput>` are such inputs. +If you've understood the `<List>` component, the `<Edit>` component will be no surprise. It's responsible for fetching the record, and displaying the page title. It passes the record down to the `<SimpleForm>` component, which is responsible for the form layout, default values, and validation. Just like `<Datagrid>`, `<SimpleForm>` uses its children to determine the form inputs to display. It expects *input components* as children. `<TextInput>` and `<ReferenceInput>` are such inputs. -The `<ReferenceInput>` takes the same props as the `<ReferenceField>` (used earlier in the `<PostList>` page). `<ReferenceInput>` uses these props to fetch the API for possible references related to the current record (in this case, possible `users` for the current `post`). It then creates a context with the possible choices and renders its children (`<SelectInput>` in this case), which are responsible for displaying the choices (via their `name` in that case), and letting the user select one. `<SelectInput>` renders as a `<select>` tag in HTML. +The `<ReferenceInput>` takes the same props as the `<ReferenceField>` (used earlier in the `<PostList>` page). `<ReferenceInput>` uses these props to fetch the API for possible references related to the current record (in this case, possible `users` for the current `post`). It then creates a context with the possible choices and renders an `<AutocompleteInput>`, which is responsible for displaying the choices, and letting the user select one. ## Adding Creation Capabilities @@ -534,7 +528,6 @@ import { + Create, SimpleForm, ReferenceInput, - SelectInput, TextInput, } from 'react-admin'; @@ -549,9 +542,7 @@ export const PostEdit = props => ( +export const PostCreate = props => ( + <Create {...props}> + <SimpleForm> -+ <ReferenceInput source="userId" reference="users"> -+ <SelectInput optionText="name" /> -+ </ReferenceInput> ++ <ReferenceInput source="userId" reference="users" /> + <TextInput source="title" /> + <TextInput multiline source="body" /> + </SimpleForm> @@ -637,13 +628,11 @@ React-admin can use Input components to create a multi-criteria search engine in ```jsx // in src/posts.js -import { ReferenceInput, SelectInput, TextInput, List } from 'react-admin'; +import { ReferenceInput, TextInput, List } from 'react-admin'; const postFilters = [ <TextInput source="q" label="Search" alwaysOn />, - <ReferenceInput source="userId" label="User" reference="users"> - <SelectInput optionText="name" /> - </ReferenceInput>, + <ReferenceInput source="userId" label="User" reference="users" />, ]; export const PostList = () => ( diff --git a/examples/simple/src/comments/CommentList.tsx b/examples/simple/src/comments/CommentList.tsx index 566ad76e9c2..b8a7e87f17d 100644 --- a/examples/simple/src/comments/CommentList.tsx +++ b/examples/simple/src/comments/CommentList.tsx @@ -22,7 +22,6 @@ import { ReferenceField, ReferenceInput, SearchInput, - SelectInput, ShowButton, SimpleList, TextField, @@ -34,9 +33,7 @@ import { const commentFilters = [ <SearchInput source="q" alwaysOn />, - <ReferenceInput source="post_id" reference="posts"> - <SelectInput optionText="title" /> - </ReferenceInput>, + <ReferenceInput source="post_id" reference="posts" />, ]; const exporter = (records, fetchRelatedRecords) => diff --git a/examples/simple/src/comments/index.tsx b/examples/simple/src/comments/index.tsx index 615907ee81e..e8d6f9dbdd0 100644 --- a/examples/simple/src/comments/index.tsx +++ b/examples/simple/src/comments/index.tsx @@ -3,10 +3,9 @@ import CommentCreate from './CommentCreate'; import CommentEdit from './CommentEdit'; import CommentList from './CommentList'; import CommentShow from './CommentShow'; -import { ListGuesser } from 'react-admin'; export default { - list: ListGuesser, + list: CommentList, create: CommentCreate, edit: CommentEdit, show: CommentShow, diff --git a/packages/ra-ui-materialui/src/detail/EditGuesser.spec.tsx b/packages/ra-ui-materialui/src/detail/EditGuesser.spec.tsx index c30a0d7e620..a699fb12fc7 100644 --- a/packages/ra-ui-materialui/src/detail/EditGuesser.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/EditGuesser.spec.tsx @@ -36,14 +36,14 @@ describe('<EditGuesser />', () => { }); expect(logSpy).toHaveBeenCalledWith(`Guessed Edit: -import { DateInput, Edit, NumberInput, ReferenceInput, SelectInput, SimpleForm, TextInput } from 'react-admin'; +import { DateInput, Edit, NumberInput, ReferenceInput, SimpleForm, TextInput } from 'react-admin'; export const CommentEdit = () => ( <Edit> <SimpleForm> <TextInput source="id" /> <TextInput source="author" /> - <ReferenceInput source="post_id" reference="posts"><SelectInput optionText="id" /></ReferenceInput> + <ReferenceInput source="post_id" reference="posts" /> <NumberInput source="score" /> <TextInput source="body" /> <DateInput source="created_at" /> diff --git a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx index 50c4ca50854..e3b7bf4b8dd 100644 --- a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx +++ b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx @@ -74,15 +74,7 @@ ${children.map(child => ` ${child.getRepresentation()}`).join('\n')} props: ReferenceInputProps, children: InferredElement ) => - `<ReferenceInput source="${props.source}" reference="${ - props.reference - }">${children.getRepresentation()}</ReferenceInput>`, - }, - referenceChild: { - component: (props: { children: ReactNode } & InputProps) => ( - <SelectInput optionText="id" {...props} /> - ), // eslint-disable-line react/display-name - representation: () => `<SelectInput optionText="id" />`, + `<ReferenceInput source="${props.source}" reference="${props.reference}" />`, }, referenceArray: { component: ReferenceArrayInput, diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index ce0632eaee2..6fdb0c89b7d 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -17,7 +17,7 @@ import { AutocompleteInput } from './AutocompleteInput'; * (using `dataProvider.getList()`), then renders an `<AutocompleteInput>`, * to which it passes the possible choices via a `ChoicesContext`. * - * You can pas a child select component to customize the way the reference + * You can pass a child select component to customize the way the reference * selector is displayed (e.g. using `<SelectInput>` or `<RadioButtonGroupInput>` * instead of `<AutocompleteInput>`). * From 062b3b72d587050b4193a0d3d5714def1d6d3d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 00:14:19 +0200 Subject: [PATCH 14/23] Fix e2e test --- cypress/integration/create.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/create.js b/cypress/integration/create.js index 7b0521ae23d..679e86e2032 100644 --- a/cypress/integration/create.js +++ b/cypress/integration/create.js @@ -90,7 +90,7 @@ describe('Create Page', () => { value: 'Annamarie Mayer', }, ]); - cy.get('[role="option"]').trigger('click'); + cy.get('[role="option"]:first').trigger('click'); cy.get(CreatePage.elements.input('authors.0.role')).should( el => expect(el).to.exist ); From 4d34a641480e0dcf8e05c63a35c9016e49529861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 00:24:03 +0200 Subject: [PATCH 15/23] Doc proofreading --- docs/AutocompleteInput.md | 15 +++++++-------- docs/ReferenceInput.md | 6 +++--- docs/SelectInput.md | 6 +++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/AutocompleteInput.md b/docs/AutocompleteInput.md index 3bc6250c7a1..7474af6042d 100644 --- a/docs/AutocompleteInput.md +++ b/docs/AutocompleteInput.md @@ -49,7 +49,7 @@ import { AutocompleteInput, ReferenceInput } from 'react-admin'; | `optionValue` | Optional | `string` | `id` | Field name of record containing the value to use as input value | | `inputText` | Optional | `Function` | `-` | Required if `optionText` is a custom Component, this function must return the text displayed for the current selection. | | `filterToQuery` | Optional | `string` => `Object` | `searchText => ({ q: [searchText] })` | How to transform the searchText into a parameter for the data provider | -| `setFilter` | Optional | `Function` | `null` | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically setup when using `ReferenceInput`. | +| `setFilter` | Optional | `Function` | `null` | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically set up when using `ReferenceInput`. | | `shouldRenderSuggestions` | Optional | `Function` | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. Use this when working with large collections of data to improve performance and user experience. This function is passed into the underlying react-autosuggest component. Ex.`(value) => value.trim().length > 2` | | `suggestionLimit` | Optional | `number` | `null` | Limits the numbers of suggestions that are shown in the dropdown list | @@ -78,7 +78,7 @@ const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; <AutocompleteInput source="author_id" choices={choices} optionText={optionRenderer} /> ``` -`optionText` also accepts a custom Component. However, as the underlying Autocomplete component requires that the current selection is a string, if you opt for a Component, you must pass a function as the `inputText` prop. This function should return text representation of the current selection: +`optionText` also accepts a custom Component. However, as the underlying Autocomplete component requires that the current selection is a string, if you opt for a Component, you must pass a function as the `inputText` prop. This function should return a text representation of the current selection: ```jsx const choices = [ @@ -128,14 +128,14 @@ In that case, set the `translateChoice` prop to `false`. ## `shouldRenderSuggestions` -When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain usable performance. The `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly, and might be all you need (depending on your data set). +When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain usable performance. The `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly and might be all you need (depending on your data set). Ex. `<AutocompleteInput shouldRenderSuggestions={(val) => { return val.trim().length > 2 }} />` would not render any suggestions until the 3rd character has been entered. This prop is passed to the underlying `react-autosuggest` component and is documented [here](https://github.com/moroshko/react-autosuggest#should-render-suggestions-prop). ## `sx`: CSS API This component doesn't apply any custom styles on top of [MUI `<Autocomplete>` component](https://mui.com/components/autocomplete/). Refer to their documentation to know its CSS API. -## Additonal Props +## Additional Props `<AutocompleteInput>` renders a [MUI `<Autocomplete>` component](https://mui.com/components/autocomplete/) and it accepts the `<Autocomplete>` props: @@ -181,7 +181,7 @@ const PostCreate = () => { ``` {% endraw %} -Use the `create` prop when you want a more polished or complex UI. For example a MUI `<Dialog>` asking for multiple fields because the choices are from a referenced resource. +Use the `create` prop when you want a more polished or complex UI. For example an MUI `<Dialog>` asking for multiple fields because the choices are from a referenced resource. {% raw %} ```js @@ -263,9 +263,9 @@ const CreateCategory = () => { ``` {% endraw %} -**Tip:** As showcased in this example, react-admin provides a convenience hook for accessing the filter the user has already input in the `<AutocompleteInput>`: `useCreateSuggestionContext`. +**Tip:** As showcased in this example, react-admin provides a convenient hook for accessing the filter the user has already input in the `<AutocompleteInput>`: `useCreateSuggestionContext`. -The `Create %{item}` option will only be displayed once the user has already set a filter (by typing in some input). If you expect your users to create new items often, you can make this more user friendly by adding a placeholder text like this: +The `Create %{item}` option will only be displayed once the user has already set a filter (by typing in some input). If you expect your users to create new items often, you can make this more user-friendly by adding a placeholder text like this: {% raw %} ```diff @@ -297,4 +297,3 @@ const PostCreate = () => { } ``` {% endraw %} - diff --git a/docs/ReferenceInput.md b/docs/ReferenceInput.md index 5896980b78d..3916469d2c9 100644 --- a/docs/ReferenceInput.md +++ b/docs/ReferenceInput.md @@ -38,7 +38,7 @@ You can tweak how this component fetches the possible values using the `page`, ` | `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | How to order the list of suggestions | | `enableGetChoices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | -**Note**: `<ReferenceInput>` doesn't accept the [common input props](./Inputs.md#common-input-props) (like `label`) ; it is the responsability of the child component to apply them. +**Note**: `<ReferenceInput>` doesn't accept the [common input props](./Inputs.md#common-input-props) (like `label`) ; it is the responsibility of the child component to apply them. ## `children` @@ -66,7 +66,7 @@ import { ReferenceInput, SelectInput } from 'react-admin'; </ReferenceInput> ``` -You can even use a component of your own as child, provided its detects a `ChoicesContext` is available and gets their choices from it. +You can even use a component of your own as child, provided it detects a `ChoicesContext` is available and gets their choices from it. The choices context value can be accessed with the [`useChoicesContext`](./useChoicesContext.md) hook. @@ -101,7 +101,7 @@ By default, `<ReferenceInput>` fetches only the first 25 values. You can extend ## `reference` -The name of the reference resource. For instance, in a Post form, if you wanrt to edit the post author, the reference should be "authors". +The name of the reference resource. For instance, in a Post form, if you want to edit the post author, the reference should be "authors". ```jsx <ReferenceInput source="author_id" reference="authors" /> diff --git a/docs/SelectInput.md b/docs/SelectInput.md index f70bbed82d7..e075058fe67 100644 --- a/docs/SelectInput.md +++ b/docs/SelectInput.md @@ -46,7 +46,7 @@ If, instead of showing choices as a dropdown list, you prefer to display them as ## `choices` -An array of objects representing the choices to show in the dropdown. The objects must have at least two fields: one to use for the option name, and the other to use for the option value. By default, `<SelectInput>` will use the `id` and `name` fields. +An array of objects that represents the choices to show in the dropdown. The objects must have at least two fields: one to use for the option name, and the other to use for the option value. By default, `<SelectInput>` will use the `id` and `name` fields. ```jsx const choices = [ @@ -216,7 +216,7 @@ import { SelectInput, ReferenceInput } from 'react-admin'; </ReferenceInput> ``` -In that case, `<SelectInput>` uses the [`recordRepresentation`](./Resource.md#recordrepresentation) to render each choice from the list of possible records. You can oferride this behavior by setting the `optionText` prop: +In that case, `<SelectInput>` uses the [`recordRepresentation`](./Resource.md#recordrepresentation) to render each choice from the list of possible records. You can override this behavior by setting the `optionText` prop: ```jsx import { SelectInput, ReferenceInput } from 'react-admin'; @@ -262,7 +262,7 @@ const PostCreate = () => { ``` {% endraw %} -Use the `create` prop when you want a more polished or complex UI. For example a MUI `<Dialog>` asking for multiple fields because the choices are from a referenced resource. +Use the `create` prop when you want a more polished or complex UI. For example an MUI `<Dialog>` asking for multiple fields because the choices are from a referenced resource. {% raw %} ```jsx From 18401cbf74384d7924037046dbc736f6714afe8a Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 17:45:14 +0200 Subject: [PATCH 16/23] Add tests for RefrenceField and ReferenceOneField --- .../src/core/ResourceDefinitionContext.tsx | 3 + .../core/useGetRecordRepresentation.spec.tsx | 71 ++++++++----------- .../src/core/useResourceDefinition.spec.tsx | 8 ++- .../src/field/ReferenceField.spec.tsx | 71 ++++++++++++++++++- .../src/field/ReferenceOneField.spec.tsx | 15 ++++ .../src/field/ReferenceOneField.stories.tsx | 3 +- 6 files changed, 124 insertions(+), 47 deletions(-) create mode 100644 packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx diff --git a/packages/ra-core/src/core/ResourceDefinitionContext.tsx b/packages/ra-core/src/core/ResourceDefinitionContext.tsx index 6d4bb29c449..c2f088377bf 100644 --- a/packages/ra-core/src/core/ResourceDefinitionContext.tsx +++ b/packages/ra-core/src/core/ResourceDefinitionContext.tsx @@ -45,6 +45,9 @@ export const ResourceDefinitionContext = createContext< export const ResourceDefinitionContextProvider = ({ definitions: defaultDefinitions = {}, children, +}: { + definitions: ResourceDefinitions; + children: React.ReactNode; }) => { const [definitions, setState] = useState<ResourceDefinitions>( defaultDefinitions diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx index 7853fee1c13..2a8011f93de 100644 --- a/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx +++ b/packages/ra-core/src/core/useGetRecordRepresentation.spec.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { render, screen } from '@testing-library/react'; import { useGetRecordRepresentation } from './useGetRecordRepresentation'; -import { ResourceDefinitionContext } from './ResourceDefinitionContext'; +import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext'; const UseRecordRepresentation = ({ resource, record }) => { const getRecordRepresentation = useGetRecordRepresentation(resource); @@ -18,91 +18,78 @@ describe('useRecordRepresentation', () => { }); it('should return a record field if the recordRepresentation is a string', () => { render( - <ResourceDefinitionContext.Provider - value={{ - definitions: { - users: { - name: 'users', - recordRepresentation: 'last_name', - }, + <ResourceDefinitionContextProvider + definitions={{ + users: { + name: 'users', + recordRepresentation: 'last_name', }, - register: () => {}, - unregister: () => {}, }} > <UseRecordRepresentation resource="users" record={{ id: 123, first_name: 'John', last_name: 'Doe' }} /> - </ResourceDefinitionContext.Provider> + </ResourceDefinitionContextProvider> ); screen.getByText('Doe'); }); + it('should return a deep record field if the recordRepresentation is a string with a dot', () => { render( - <ResourceDefinitionContext.Provider - value={{ - definitions: { - users: { - name: 'users', - recordRepresentation: 'name.last', - }, + <ResourceDefinitionContextProvider + definitions={{ + users: { + name: 'users', + recordRepresentation: 'name.last', }, - register: () => {}, - unregister: () => {}, }} > <UseRecordRepresentation resource="users" record={{ id: 123, name: { first: 'John', last: 'Doe' } }} /> - </ResourceDefinitionContext.Provider> + </ResourceDefinitionContextProvider> ); screen.getByText('Doe'); }); + it('should return a string if the recordRepresentation is a function', () => { render( - <ResourceDefinitionContext.Provider - value={{ - definitions: { - users: { - name: 'users', - recordRepresentation: record => - `${record.first_name} ${record.last_name}`, - }, + <ResourceDefinitionContextProvider + definitions={{ + users: { + name: 'users', + recordRepresentation: record => + `${record.first_name} ${record.last_name}`, }, - register: () => {}, - unregister: () => {}, }} > <UseRecordRepresentation resource="users" record={{ id: 123, first_name: 'John', last_name: 'Doe' }} /> - </ResourceDefinitionContext.Provider> + </ResourceDefinitionContextProvider> ); screen.getByText('John Doe'); }); + it('should return a React element if the recordRepresentation is a react element', () => { const Hello = () => <div>Hello</div>; render( - <ResourceDefinitionContext.Provider - value={{ - definitions: { - users: { - name: 'users', - recordRepresentation: <Hello />, - }, + <ResourceDefinitionContextProvider + definitions={{ + users: { + name: 'users', + recordRepresentation: <Hello />, }, - register: () => {}, - unregister: () => {}, }} > <UseRecordRepresentation resource="users" record={{ id: 123, first_name: 'John', last_name: 'Doe' }} /> - </ResourceDefinitionContext.Provider> + </ResourceDefinitionContextProvider> ); screen.getByText('Hello'); }); diff --git a/packages/ra-core/src/core/useResourceDefinition.spec.tsx b/packages/ra-core/src/core/useResourceDefinition.spec.tsx index 708996bdad6..591f274907e 100644 --- a/packages/ra-core/src/core/useResourceDefinition.spec.tsx +++ b/packages/ra-core/src/core/useResourceDefinition.spec.tsx @@ -34,9 +34,13 @@ describe('useResourceDefinition', () => { <ResourceDefinitionContextProvider definitions={{ posts: { + name: 'posts', + hasList: true, options: { label: 'Posts' }, + recordRepresentation: 'title', }, comments: { + name: 'comments', options: { label: 'Comments' }, }, }} @@ -45,10 +49,12 @@ describe('useResourceDefinition', () => { </ResourceDefinitionContextProvider> ); expect(callback).toHaveBeenCalledWith({ + name: 'posts', hasCreate: undefined, hasEdit: undefined, - hasList: undefined, + hasList: true, hasShow: undefined, + recordRepresentation: 'title', options: { label: 'Posts' }, }); }); diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx index 53e037ff0a8..5d4e8beed0c 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx @@ -6,6 +6,7 @@ import { CoreAdminContext, testDataProvider, useGetMany, + ResourceDefinitionContextProvider, } from 'ra-core'; import { QueryClient } from 'react-query'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -249,6 +250,70 @@ describe('<ReferenceField />', () => { }); it('should use record from RecordContext', async () => { + const dataProvider = testDataProvider({ + getMany: jest.fn().mockResolvedValue({ + data: [{ id: 123, title: 'foo' }], + }), + }); + render( + <ThemeProvider theme={theme}> + <CoreAdminContext dataProvider={dataProvider}> + <RecordContextProvider value={record}> + <ReferenceField + resource="comments" + source="postId" + reference="posts" + /> + </RecordContextProvider> + </CoreAdminContext> + </ThemeProvider> + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(screen.getByText('#123')).not.toBeNull(); + expect(screen.queryAllByRole('link')).toHaveLength(1); + expect(screen.queryByRole('link')?.getAttribute('href')).toBe( + '#/posts/123' + ); + }); + + it('should use recordRepresentation to render the related record', async () => { + const dataProvider = testDataProvider({ + getMany: jest.fn().mockResolvedValue({ + data: [{ id: 123, title: 'foo' }], + }), + }); + render( + <ThemeProvider theme={theme}> + <CoreAdminContext dataProvider={dataProvider}> + <ResourceDefinitionContextProvider + definitions={{ + posts: { + recordRepresentation: 'title', + }, + }} + > + <RecordContextProvider value={record}> + <ReferenceField + resource="comments" + source="postId" + reference="posts" + /> + </RecordContextProvider> + </ResourceDefinitionContextProvider> + </CoreAdminContext> + </ThemeProvider> + ); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect(screen.getByText('foo')).not.toBeNull(); + expect(screen.queryAllByRole('link')).toHaveLength(1); + expect(screen.queryByRole('link')?.getAttribute('href')).toBe( + '#/posts/123' + ); + }); + + it('should render its child component when given', async () => { const dataProvider = testDataProvider({ getMany: jest.fn().mockResolvedValue({ data: [{ id: 123, title: 'foo' }], @@ -273,7 +338,7 @@ describe('<ReferenceField />', () => { expect(screen.queryByRole('progressbar')).toBeNull(); expect(screen.getByText('foo')).not.toBeNull(); expect(screen.queryAllByRole('link')).toHaveLength(1); - expect(screen.queryByRole('link').getAttribute('href')).toBe( + expect(screen.queryByRole('link')?.getAttribute('href')).toBe( '#/posts/123' ); }); @@ -328,7 +393,7 @@ describe('<ReferenceField />', () => { hidden: true, }); expect(ErrorIcon).not.toBeNull(); - expect(ErrorIcon.getAttribute('aria-errormessage')).toBe('boo'); + expect(ErrorIcon?.getAttribute('aria-errormessage')).toBe('boo'); }); it('should render a link to specified link type', async () => { @@ -355,7 +420,7 @@ describe('<ReferenceField />', () => { await waitFor(() => expect(dataProvider.getMany).toHaveBeenCalledTimes(1) ); - expect(screen.queryByRole('link').getAttribute('href')).toBe( + expect(screen.queryByRole('link')?.getAttribute('href')).toBe( '#/posts/123/show' ); }); diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx new file mode 100644 index 00000000000..937c622079a --- /dev/null +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { RecordRepresentation, Basic } from './ReferenceOneField.stories'; + +describe('ReferenceOneField', () => { + it('should render the recordRepresentation of the related record', async () => { + render(<RecordRepresentation />); + await screen.findByText('Genre: novel, ISBN: 9780393966473'); + }); + it('should render its child in the context of the related record', async () => { + render(<Basic />); + await screen.findByText('9780393966473'); + }); +}); diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx index 3e02b1016d1..bd7dddaf36c 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx @@ -102,7 +102,7 @@ export const Link = () => ( ); export const Multiple = () => { - const [calls, setCalls] = useState([]); + const [calls, setCalls] = useState<any>([]); const dataProviderWithLogging = { getManyReference: (resource, params) => { setCalls(calls => @@ -205,6 +205,7 @@ const BookDetailsRepresentation = () => { </> ); }; + export const RecordRepresentation = () => ( <CoreAdminContext dataProvider={defaultDataProvider} history={history}> <ResourceContextProvider value="books"> From 46b78636ff153a627b474b44e49b13fc4be5c57a Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 17:59:11 +0200 Subject: [PATCH 17/23] Add tests for Edit and Show defaultTitle --- .../ra-ui-materialui/src/detail/Edit.spec.tsx | 95 +++++++++++++++++-- .../ra-ui-materialui/src/detail/Show.spec.tsx | 60 ++++++++++++ 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx index cafe97aa347..a543f6a69b3 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx @@ -12,7 +12,11 @@ import { undoableEventEmitter, useRecordContext, useSaveContext, + useEditContext, + ResourceDefinitionContextProvider, } from 'ra-core'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; import { AdminContext } from '../AdminContext'; import { Edit } from './Edit'; @@ -21,8 +25,6 @@ describe('<Edit />', () => { const defaultEditProps = { id: '123', resource: 'foo', - location: {} as any, - match: {} as any, }; it('should call dataProvider.getOne() and pass the result to its child as record', async () => { @@ -61,7 +63,11 @@ describe('<Edit />', () => { return ( <> <span>{record.title}</span> - <button onClick={() => save({ ...record, title: 'ipsum' })}> + <button + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } + > Update </button> </> @@ -112,7 +118,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -168,7 +176,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -222,7 +232,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -281,7 +293,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -337,6 +351,7 @@ describe('<Edit />', () => { <span>{record.title}</span> <button onClick={() => + save && save( { ...record, title: 'ipsum' }, { @@ -401,7 +416,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -455,6 +472,7 @@ describe('<Edit />', () => { <span>{record.title}</span> <button onClick={() => + save && save( { ...record, title: 'ipsum' }, { @@ -525,7 +543,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -590,6 +610,7 @@ describe('<Edit />', () => { <span>{record.title}</span> <button onClick={() => + save && save( { ...record, title: 'ipsum' }, { @@ -658,7 +679,9 @@ describe('<Edit />', () => { <> <span>{record.title}</span> <button - onClick={() => save({ ...record, title: 'ipsum' })} + onClick={() => + save && save({ ...record, title: 'ipsum' }) + } > Update </button> @@ -722,6 +745,7 @@ describe('<Edit />', () => { <span>{record.title}</span> <button onClick={() => + save && save( { ...record, title: 'ipsum' }, { @@ -785,4 +809,55 @@ describe('<Edit />', () => { expect(screen.queryAllByText('Hello')).toHaveLength(1); }); }); + + describe('defaultTitle', () => { + it('should use the record id by default', async () => { + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + } as any; + const Title = () => { + const { defaultTitle } = useEditContext(); + return <>{defaultTitle}</>; + }; + const i18nProvider = polyglotI18nProvider(() => englishMessages); + render( + <AdminContext + dataProvider={dataProvider} + i18nProvider={i18nProvider} + > + <Edit {...defaultEditProps}> + <Title /> + </Edit> + </AdminContext> + ); + await screen.findByText('Foo #123'); + }); + it('should use the recordRepresentation when defined', async () => { + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + } as any; + const Title = () => { + const { defaultTitle } = useEditContext(); + return <>{defaultTitle}</>; + }; + const i18nProvider = polyglotI18nProvider(() => englishMessages); + render( + <AdminContext + dataProvider={dataProvider} + i18nProvider={i18nProvider} + > + <ResourceDefinitionContextProvider + definitions={{ foo: { recordRepresentation: 'title' } }} + > + <Edit {...defaultEditProps}> + <Title /> + </Edit> + </ResourceDefinitionContextProvider> + </AdminContext> + ); + await screen.findByText('Foo lorem'); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/detail/Show.spec.tsx b/packages/ra-ui-materialui/src/detail/Show.spec.tsx index 26aaa9d6b49..103b049d4ad 100644 --- a/packages/ra-ui-materialui/src/detail/Show.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.spec.tsx @@ -4,11 +4,16 @@ import { CoreAdminContext, ResourceContextProvider, useRecordContext, + useShowContext, + ResourceDefinitionContextProvider, } from 'ra-core'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; import { createMemoryHistory } from 'history'; import { Route, Routes } from 'react-router-dom'; import { render, screen, waitFor } from '@testing-library/react'; +import { AdminContext } from '../AdminContext'; import { Default, Actions, Basic, Component } from './Show.stories'; import { Show } from './Show'; @@ -119,4 +124,59 @@ describe('<Show />', () => { render(<Component />); expect(screen.getByTestId('custom-component')).toBeDefined(); }); + + describe('defaultTitle', () => { + const defaultShowProps = { + id: '123', + resource: 'foo', + }; + it('should use the record id by default', async () => { + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + } as any; + const Title = () => { + const { defaultTitle } = useShowContext(); + return <>{defaultTitle}</>; + }; + const i18nProvider = polyglotI18nProvider(() => englishMessages); + render( + <AdminContext + dataProvider={dataProvider} + i18nProvider={i18nProvider} + > + <Show {...defaultShowProps}> + <Title /> + </Show> + </AdminContext> + ); + await screen.findByText('Foo #123'); + }); + it('should use the recordRepresentation when defined', async () => { + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + } as any; + const Title = () => { + const { defaultTitle } = useShowContext(); + return <>{defaultTitle}</>; + }; + const i18nProvider = polyglotI18nProvider(() => englishMessages); + render( + <AdminContext + dataProvider={dataProvider} + i18nProvider={i18nProvider} + > + <ResourceDefinitionContextProvider + definitions={{ foo: { recordRepresentation: 'title' } }} + > + <Show {...defaultShowProps}> + <Title /> + </Show> + </ResourceDefinitionContextProvider> + </AdminContext> + ); + await screen.findByText('Foo lorem'); + }); + }); }); From 72f8d5205ea9b1dafeacc30efe81ab9fa91657e5 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 18:10:07 +0200 Subject: [PATCH 18/23] Refactor SelectInput tests and add test for recordRepresentation --- .../src/input/SelectInput.spec.tsx | 785 +++++++++--------- 1 file changed, 413 insertions(+), 372 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 101f3fc16e6..20ac65da82c 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + findByText, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { required, testDataProvider, @@ -11,6 +17,7 @@ import { AdminContext } from '../AdminContext'; import { SimpleForm } from '../form'; import { SelectInput } from './SelectInput'; import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; +import { InsideReferenceInput } from './SelectInput.stories'; describe('<SelectInput />', () => { const defaultProps = { @@ -34,300 +41,312 @@ describe('<SelectInput />', () => { </AdminContext> ); const input = container.querySelector('input'); - expect(input.value).toEqual('ang'); + expect(input?.value).toEqual('ang'); }); - it('should render choices as mui MenuItem components', async () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput {...defaultProps} /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); - expect(screen.queryAllByRole('option').length).toEqual(3); + describe('choices', () => { + it('should render choices as mui MenuItem components', async () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput {...defaultProps} /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); + expect(screen.queryAllByRole('option').length).toEqual(3); - expect( - screen - .getByTitle('ra.action.clear_input_value') - .getAttribute('data-value') - ).toEqual(''); + expect( + screen + .getByTitle('ra.action.clear_input_value') + .getAttribute('data-value') + ).toEqual(''); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); - expect(screen.getByText('React').getAttribute('data-value')).toEqual( - 'rea' - ); - }); + expect( + screen.getByText('React').getAttribute('data-value') + ).toEqual('rea'); + }); - it('should render disable choices marked so', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - choices={[ - { id: 'ang', name: 'Angular' }, - { id: 'rea', name: 'React', disabled: true }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + it('should render disable choices marked so', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + choices={[ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React', disabled: true }, + ]} + /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect( - screen.getByText('Angular').getAttribute('aria-disabled') - ).toBeNull(); - expect(screen.getByText('React').getAttribute('aria-disabled')).toEqual( - 'true' - ); + expect( + screen.getByText('Angular').getAttribute('aria-disabled') + ).toBeNull(); + expect( + screen.getByText('React').getAttribute('aria-disabled') + ).toEqual('true'); + }); }); - it('should allow to override the empty menu option text by passing a string', () => { - const emptyText = 'Default'; - - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput emptyText={emptyText} {...defaultProps} /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); - - expect(screen.queryAllByRole('option').length).toEqual(3); - - expect(screen.getByText('Default')).not.toBeNull(); - }); + describe('emptyText', () => { + it('should allow to override the empty menu option text by passing a string', () => { + const emptyText = 'Default'; - it('should allow to override the empty menu option text by passing a React element', () => { - const emptyText = ( - <div> - <em>Empty choice</em> - </div> - ); + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput emptyText={emptyText} {...defaultProps} /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput emptyText={emptyText} {...defaultProps} /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + expect(screen.queryAllByRole('option').length).toEqual(3); - expect(screen.queryAllByRole('option').length).toEqual(3); + expect(screen.getByText('Default')).not.toBeNull(); + }); - expect(screen.getByText('Empty choice')).not.toBeNull(); - }); + it('should allow to override the empty menu option text by passing a React element', () => { + const emptyText = ( + <div> + <em>Empty choice</em> + </div> + ); - it('should use optionValue as value identifier', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionValue="foobar" - choices={[ - { foobar: 'ang', name: 'Angular' }, - { foobar: 'rea', name: 'React' }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput emptyText={emptyText} {...defaultProps} /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); - }); + expect(screen.queryAllByRole('option').length).toEqual(3); - it('should use optionValue including "." as value identifier', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionValue="foobar.id" - choices={[ - { foobar: { id: 'ang' }, name: 'Angular' }, - { foobar: { id: 'rea' }, name: 'React' }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); - - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); + expect(screen.getByText('Empty choice')).not.toBeNull(); + }); }); - it('should use optionText with a string value as text identifier', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionText="foobar" - choices={[ - { id: 'ang', foobar: 'Angular' }, - { id: 'rea', foobar: 'React' }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + describe('optionValue', () => { + it('should use optionValue as value identifier', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + optionValue="foobar" + choices={[ + { foobar: 'ang', name: 'Angular' }, + { foobar: 'rea', name: 'React' }, + ]} + /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); - }); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); - it('should use optionText with a string value including "." as text identifier', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionText="foobar.name" - choices={[ - { id: 'ang', foobar: { name: 'Angular' } }, - { id: 'rea', foobar: { name: 'React' } }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + it('should use optionValue including "." as value identifier', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + optionValue="foobar.id" + choices={[ + { foobar: { id: 'ang' }, name: 'Angular' }, + { foobar: { id: 'rea' }, name: 'React' }, + ]} + /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); }); - it('should use optionText with a function value as text identifier', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionText={choice => choice.foobar} - choices={[ - { id: 'ang', foobar: 'Angular' }, - { id: 'rea', foobar: 'React' }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + describe('optionText', () => { + it('should use optionText with a string value as text identifier', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + optionText="foobar" + choices={[ + { id: 'ang', foobar: 'Angular' }, + { id: 'rea', foobar: 'React' }, + ]} + /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); - }); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); - it('should use optionText with an element value as text identifier', () => { - const Foobar = () => { - const record = useRecordContext(); - return <span data-value={record.id} aria-label={record.foobar} />; - }; - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - optionText={<Foobar />} - choices={[ - { id: 'ang', foobar: 'Angular' }, - { id: 'rea', foobar: 'React' }, - ]} - /> - </SimpleForm> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('resources.posts.fields.language') - ); + it('should use optionText with a string value including "." as text identifier', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + optionText="foobar.name" + choices={[ + { id: 'ang', foobar: { name: 'Angular' } }, + { id: 'rea', foobar: { name: 'React' } }, + ]} + /> + </SimpleForm> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect( - screen.getByLabelText('Angular').getAttribute('data-value') - ).toEqual('ang'); - }); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); - it('should translate the choices by default', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <TestTranslationProvider translate={x => `**${x}**`}> + it('should use optionText with a function value as text identifier', () => { + render( + <AdminContext dataProvider={testDataProvider()}> <SimpleForm onSubmit={jest.fn()}> - <SelectInput {...defaultProps} /> + <SelectInput + {...defaultProps} + optionText={choice => choice.foobar} + choices={[ + { id: 'ang', foobar: 'Angular' }, + { id: 'rea', foobar: 'React' }, + ]} + /> </SimpleForm> - </TestTranslationProvider> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('**resources.posts.fields.language**') - ); + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.queryAllByRole('option').length).toEqual(3); - expect( - screen.getByText('**Angular**').getAttribute('data-value') - ).toEqual('ang'); - expect( - screen.getByText('**React**').getAttribute('data-value') - ).toEqual('rea'); - }); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); - it('should not translate the choices if translateChoice is false', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <TestTranslationProvider translate={x => `**${x}**`}> + it('should use optionText with an element value as text identifier', () => { + const Foobar = () => { + const record = useRecordContext(); + return ( + <span data-value={record.id} aria-label={record.foobar} /> + ); + }; + render( + <AdminContext dataProvider={testDataProvider()}> <SimpleForm onSubmit={jest.fn()}> <SelectInput {...defaultProps} - translateChoice={false} + optionText={<Foobar />} + choices={[ + { id: 'ang', foobar: 'Angular' }, + { id: 'rea', foobar: 'React' }, + ]} /> </SimpleForm> - </TestTranslationProvider> - </AdminContext> - ); - fireEvent.mouseDown( - screen.getByLabelText('**resources.posts.fields.language**') - ); + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('resources.posts.fields.language') + ); - expect(screen.queryAllByRole('option').length).toEqual(3); - expect(screen.getByText('Angular').getAttribute('data-value')).toEqual( - 'ang' - ); - expect(screen.getByText('React').getAttribute('data-value')).toEqual( - 'rea' - ); + expect( + screen.getByLabelText('Angular').getAttribute('data-value') + ).toEqual('ang'); + }); + }); + + describe('translateChoice', () => { + it('should translate the choices by default', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <TestTranslationProvider translate={x => `**${x}**`}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput {...defaultProps} /> + </SimpleForm> + </TestTranslationProvider> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('**resources.posts.fields.language**') + ); + + expect(screen.queryAllByRole('option').length).toEqual(3); + expect( + screen.getByText('**Angular**').getAttribute('data-value') + ).toEqual('ang'); + expect( + screen.getByText('**React**').getAttribute('data-value') + ).toEqual('rea'); + }); + + it('should not translate the choices if translateChoice is false', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <TestTranslationProvider translate={x => `**${x}**`}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + translateChoice={false} + /> + </SimpleForm> + </TestTranslationProvider> + </AdminContext> + ); + fireEvent.mouseDown( + screen.getByLabelText('**resources.posts.fields.language**') + ); + + expect(screen.queryAllByRole('option').length).toEqual(3); + expect( + screen.getByText('Angular').getAttribute('data-value') + ).toEqual('ang'); + expect( + screen.getByText('React').getAttribute('data-value') + ).toEqual('rea'); + }); }); it('should display helperText if prop is present', () => { @@ -416,140 +435,156 @@ describe('<SelectInput />', () => { }); }); - it('should not render a LinearProgress if isLoading is true and a second has not passed yet', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput {...defaultProps} isLoading /> - </SimpleForm> - </AdminContext> - ); + describe('loading', () => { + it('should not render a LinearProgress if isLoading is true and a second has not passed yet', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput {...defaultProps} isLoading /> + </SimpleForm> + </AdminContext> + ); - expect(screen.queryByRole('progressbar')).toBeNull(); - }); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); - it('should render a LinearProgress if isLoading is true and a second has passed', async () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput {...defaultProps} isLoading /> - </SimpleForm> - </AdminContext> - ); + it('should render a LinearProgress if isLoading is true and a second has passed', async () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput {...defaultProps} isLoading /> + </SimpleForm> + </AdminContext> + ); - await new Promise(resolve => setTimeout(resolve, 1001)); + await new Promise(resolve => setTimeout(resolve, 1001)); - expect(screen.queryByRole('progressbar')).not.toBeNull(); - }); + expect(screen.queryByRole('progressbar')).not.toBeNull(); + }); - it('should not render a LinearProgress if isLoading is false', () => { - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput {...defaultProps} /> - </SimpleForm> - </AdminContext> - ); + it('should not render a LinearProgress if isLoading is false', () => { + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput {...defaultProps} /> + </SimpleForm> + </AdminContext> + ); - expect(screen.queryByRole('progressbar')).toBeNull(); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); }); - it('should support creation of a new choice through the onCreate event', async () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - const choices = [...defaultProps.choices]; - const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + describe('onCreate', () => { + it('should support creation of a new choice through the onCreate event', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const choices = [...defaultProps.choices]; + const newChoice = { + id: 'js_fatigue', + name: 'New Kid On The Block', + }; - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - choices={choices} - onCreate={() => { - choices.push(newChoice); - return newChoice; - }} - /> - </SimpleForm> - </AdminContext> - ); + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + choices={choices} + onCreate={() => { + choices.push(newChoice); + return newChoice; + }} + /> + </SimpleForm> + </AdminContext> + ); - const input = screen.getByLabelText('resources.posts.fields.language'); - fireEvent.mouseDown(input); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ); + fireEvent.mouseDown(input); - fireEvent.click(screen.getByText('ra.action.create')); - await waitFor(() => { - expect(screen.queryByText(newChoice.name)).not.toBeNull(); + fireEvent.click(screen.getByText('ra.action.create')); + await waitFor(() => { + expect(screen.queryByText(newChoice.name)).not.toBeNull(); + }); }); - }); - it('should support creation of a new choice through the onCreate event with a promise', async () => { - const choices = [...defaultProps.choices]; - const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + it('should support creation of a new choice through the onCreate event with a promise', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { + id: 'js_fatigue', + name: 'New Kid On The Block', + }; - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - choices={choices} - defaultValue="ang" - onCreate={() => { - return new Promise(resolve => { - setTimeout(() => { - choices.push(newChoice); - resolve(newChoice); - }, 50); - }); - }} - /> - </SimpleForm> - </AdminContext> - ); + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + choices={choices} + defaultValue="ang" + onCreate={() => { + return new Promise(resolve => { + setTimeout(() => { + choices.push(newChoice); + resolve(newChoice); + }, 50); + }); + }} + /> + </SimpleForm> + </AdminContext> + ); - const input = screen.getByLabelText('resources.posts.fields.language'); - fireEvent.mouseDown(input); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ); + fireEvent.mouseDown(input); - fireEvent.click(screen.getByText('ra.action.create')); + fireEvent.click(screen.getByText('ra.action.create')); - await waitFor(() => { - expect(screen.queryByText(newChoice.name)).not.toBeNull(); + await waitFor(() => { + expect(screen.queryByText(newChoice.name)).not.toBeNull(); + }); }); - }); - it('should support creation of a new choice with nested optionText', async () => { - const choices = [ - { id: 'programming', name: { en: 'Programming' } }, - { id: 'lifestyle', name: { en: 'Lifestyle' } }, - { id: 'photography', name: { en: 'Photography' } }, - ]; - const newChoice = { - id: 'js_fatigue', - name: { en: 'New Kid On The Block' }, - }; + it('should support creation of a new choice with nested optionText', async () => { + const choices = [ + { id: 'programming', name: { en: 'Programming' } }, + { id: 'lifestyle', name: { en: 'Lifestyle' } }, + { id: 'photography', name: { en: 'Photography' } }, + ]; + const newChoice = { + id: 'js_fatigue', + name: { en: 'New Kid On The Block' }, + }; - render( - <AdminContext dataProvider={testDataProvider()}> - <SimpleForm onSubmit={jest.fn()}> - <SelectInput - {...defaultProps} - choices={choices} - onCreate={() => { - choices.push(newChoice); - return newChoice; - }} - optionText="name.en" - /> - </SimpleForm> - </AdminContext> - ); + render( + <AdminContext dataProvider={testDataProvider()}> + <SimpleForm onSubmit={jest.fn()}> + <SelectInput + {...defaultProps} + choices={choices} + onCreate={() => { + choices.push(newChoice); + return newChoice; + }} + optionText="name.en" + /> + </SimpleForm> + </AdminContext> + ); - const input = screen.getByLabelText('resources.posts.fields.language'); - fireEvent.mouseDown(input); + const input = screen.getByLabelText( + 'resources.posts.fields.language' + ); + fireEvent.mouseDown(input); - fireEvent.click(screen.getByText('ra.action.create')); - await waitFor(() => { - expect(screen.queryByText(newChoice.name.en)).not.toBeNull(); + fireEvent.click(screen.getByText('ra.action.create')); + await waitFor(() => { + expect(screen.queryByText(newChoice.name.en)).not.toBeNull(); + }); }); }); @@ -590,7 +625,7 @@ describe('<SelectInput />', () => { }); }); - it('should recive an event object on change', async () => { + it('should receive an event object on change', async () => { const choices = [...defaultProps.choices]; const onChange = jest.fn(); @@ -627,7 +662,7 @@ describe('<SelectInput />', () => { }); }); - it('should recive a value on change when creating a new choice', async () => { + it('should receive a value on change when creating a new choice', async () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); const choices = [...defaultProps.choices]; const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; @@ -666,4 +701,10 @@ describe('<SelectInput />', () => { expect(onChange).toHaveBeenCalledWith('js_fatigue'); }); }); + describe('inside ReferenceInput', () => { + it('should use the recordRepresentation as optionText', async () => { + render(<InsideReferenceInput />); + await screen.findByText('Leo Tolstoy'); + }); + }); }); From 5572fbe37c04135f0f7ae70c7512cbfb9d886349 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 18:28:40 +0200 Subject: [PATCH 19/23] Add tests for ReferenceInput --- .../src/input/ReferenceInput.spec.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.spec.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.spec.tsx index f45463f47fb..fca6bf7374b 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.spec.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from 'react-query'; +import { testDataProvider, useChoicesContext } from 'ra-core'; + import { ReferenceInput } from './ReferenceInput'; import { AdminContext } from '../AdminContext'; import { SimpleForm } from '../form'; -import { testDataProvider, useChoicesContext } from 'ra-core'; -import { QueryClient } from 'react-query'; +import { Basic } from './ReferenceInput.stories'; describe('<ReferenceInput />', () => { const defaultProps = { @@ -42,12 +44,18 @@ describe('<ReferenceInput />', () => { }); }); + it('should render an AutocompleteInput using recordRepresentation by default', async () => { + render(<Basic />); + await screen.findByDisplayValue('Leo Tolstoy'); + }); + it('should pass the correct resource down to child component', async () => { const MyComponent = () => { const { resource } = useChoicesContext(); return <div>{resource}</div>; }; const dataProvider = testDataProvider({ + // @ts-ignore getList: () => Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), }); @@ -71,6 +79,7 @@ describe('<ReferenceInput />', () => { return <div aria-label="total">{total}</div>; }; const dataProvider = testDataProvider({ + // @ts-ignore getList: () => Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), }); From fca2b89865352e078add2d6959b74c8dced4a874 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 27 Jul 2022 18:29:39 +0200 Subject: [PATCH 20/23] Fix compilation --- packages/ra-core/src/core/ResourceDefinitionContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/core/ResourceDefinitionContext.tsx b/packages/ra-core/src/core/ResourceDefinitionContext.tsx index c2f088377bf..34836ea8779 100644 --- a/packages/ra-core/src/core/ResourceDefinitionContext.tsx +++ b/packages/ra-core/src/core/ResourceDefinitionContext.tsx @@ -46,7 +46,7 @@ export const ResourceDefinitionContextProvider = ({ definitions: defaultDefinitions = {}, children, }: { - definitions: ResourceDefinitions; + definitions?: ResourceDefinitions; children: React.ReactNode; }) => { const [definitions, setState] = useState<ResourceDefinitions>( From d7c0211b52b51443a21f89689d638dc2a15728eb Mon Sep 17 00:00:00 2001 From: Francois Zaninotto <francois@marmelab.com> Date: Thu, 28 Jul 2022 10:24:47 +0200 Subject: [PATCH 21/23] Apply suggestions from code review Co-authored-by: Antoine Fricker <102964006+septentrion-730n@users.noreply.github.com> --- docs/Resource.md | 4 ++-- docs/Tutorial.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Resource.md b/docs/Resource.md index 0ea16f34230..a3ce7522ad1 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -122,7 +122,7 @@ const App = () => ( Whenever react-admin needs to render a record (e.g. in the title of an edition view, or in a `<ReferenceField>`), it uses the `recordRepresentation` to do it. By default, the representation of a record is its `id` field. But you can customize it by specifying the representation you want. -For instance, to change the default represnetation of "users" records to render the full name instead of the id: +For instance, to change the default representation of "users" records to render the full name instead of the id: ```jsx <Resource @@ -132,7 +132,7 @@ For instance, to change the default represnetation of "users" records to render /> ``` -`recordReprensentation` can take 3 types of values: +`recordRepresentation` can take 3 types of values: - a string (e.g. `'title'`) to specify the field to use as representation - a function (e.g. `(record) => record.title`) to specify a custom string representation diff --git a/docs/Tutorial.md b/docs/Tutorial.md index 5e1521eb95f..fbf86b428e2 100644 --- a/docs/Tutorial.md +++ b/docs/Tutorial.md @@ -454,7 +454,7 @@ const App = () => ( [![Post Edit Guesser](./img/tutorial_edit_guesser.gif)](./img/tutorial_edit_guesser.gif) -Users can display the edit page just by clicking on the Edit button. The form is already functional; it issues `PUT` requests to the REST API upon submission. And thanks to the `recordRepresentation` of the "users" Resource, the user name is displayed foir the post author. +Users can display the edit page just by clicking on the Edit button. The form is already functional; it issues `PUT` requests to the REST API upon submission. And thanks to the `recordRepresentation` of the "users" Resource, the user name is displayed for the post author. Copy the `<PostEdit>` code dumped by the guesser in the console to the `posts.js` file so that you can customize the view. Don't forget to `import` the new components from react-admin: From 40c65f3d34be1a218ce75bb0e0ded0c9f3405d9c Mon Sep 17 00:00:00 2001 From: Francois Zaninotto <francois@marmelab.com> Date: Thu, 28 Jul 2022 10:26:27 +0200 Subject: [PATCH 22/23] Update docs/ReferenceField.md Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- docs/ReferenceField.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index 1b5e6b4b59c..0c6af881955 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -36,7 +36,7 @@ So it's a good idea to configure the `<Resource recordRepresentation>` to render <Resource name="users" list={UserList} recordRepresentation={(record) => `${record.first_name} ${record.last_name}`} /> ``` -Alternately, if you pass a child component, `ReferenceField>` will render it instead of the `recordRepresentation`. Usual child components for `<ReferenceField>` are other `<Field>` components (e.g. [`<TextField>`](./TextField.md)). +Alternately, if you pass a child component, `<ReferenceField>` will render it instead of the `recordRepresentation`. Usual child components for `<ReferenceField>` are other `<Field>` components (e.g. [`<TextField>`](./TextField.md)). ```jsx <ReferenceField source="user_id" reference="users"> From 59a4a2abe5f615b79ca58424c0d59e7bb8f1f6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= <fzaninotto@gmail.com> Date: Thu, 28 Jul 2022 10:28:32 +0200 Subject: [PATCH 23/23] review --- packages/ra-core/src/core/useGetRecordRepresentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/core/useGetRecordRepresentation.ts b/packages/ra-core/src/core/useGetRecordRepresentation.ts index f37c5aa9e09..7286f2eccbe 100644 --- a/packages/ra-core/src/core/useGetRecordRepresentation.ts +++ b/packages/ra-core/src/core/useGetRecordRepresentation.ts @@ -29,7 +29,7 @@ export const useGetRecordRepresentation = ( return get(record, recordRepresentation); } if (React.isValidElement(recordRepresentation)) { - return React.cloneElement(recordRepresentation); + return recordRepresentation; } return `#${record.id}`; },