From f1504ef21524ec467a0bda4fe32e43d592b00a5b Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 11:06:02 +0200 Subject: [PATCH 01/25] add render prop on List component --- packages/ra-ui-materialui/src/list/List.tsx | 5 ++- .../ra-ui-materialui/src/list/ListView.tsx | 38 ++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 23b980e1c81..7e4c0966348 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -72,6 +72,7 @@ export const List = ( resource, sort, storeKey, + render, ...rest } = useThemeProps({ props: props, @@ -93,13 +94,13 @@ export const List = ( sort={sort} storeKey={storeKey} > - {...rest} /> + {...rest} render={render} /> ); }; export interface ListProps - extends Omit, 'children'>, + extends Omit, 'children' | 'render'>, ListViewProps {} const defaultFilter = {}; diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index e6cd323b79f..ee28b923a1a 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -8,7 +8,7 @@ import { import type { ReactElement, ReactNode, ElementType } from 'react'; import Card from '@mui/material/Card'; import clsx from 'clsx'; -import { useListContext, type RaRecord } from 'ra-core'; +import { ListControllerResult, useListContext, type RaRecord } from 'ra-core'; import { Title } from '../layout/Title'; import { ListToolbar } from './ListToolbar'; @@ -36,8 +36,10 @@ export const ListView = ( component: Content = DefaultComponent, title, empty = defaultEmpty, + render, ...rest } = props; + const listContext = useListContext(); const { defaultTitle, data, @@ -48,9 +50,9 @@ export const ListView = ( total, hasNextPage, hasPreviousPage, - } = useListContext(); + } = listContext; - if (!children || (!data && isPending && emptyWhileLoading)) { + if ((!children && !render) || (!data && isPending && emptyWhileLoading)) { return null; } @@ -63,7 +65,9 @@ export const ListView = ( actions={actions} /> )} - {children} + + {render ? render(listContext) : children} + {!error && pagination !== false && pagination} ); @@ -102,7 +106,7 @@ export const ListView = ( ); }; -export interface ListViewProps { +export interface ListViewProps { /** * The actions to display in the toolbar. defaults to Filter + Create + Export. * @@ -193,6 +197,30 @@ export interface ListViewProps { */ children: ReactNode; + /** + * A function rendering the list of records. Take the list controller as argument. + * + * @see https://marmelab.com/react-admin/List.html#children + * @example + * import { List } from 'react-admin'; + * + * export const BookList = () => ( + * + * {(listContext) => + * listContext.data.map(record => ( + *
+ *

{record.id}

+ *

{record.title}

+ *

{record.published_at}

+ *

{record.nb_views}

+ *
+ * ) + * } + *
+ * ); + */ + render?: (props: ListControllerResult) => ReactNode; + /** * The component used to display the list. Defaults to . * From 8be740ed315067a0b78040dd9ad7db545bebaf9d Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 12:05:10 +0200 Subject: [PATCH 02/25] add renderprops to List component --- docs/List.md | 30 ++++++++++++++++++- .../ra-ui-materialui/src/list/List.spec.tsx | 14 +++++++++ .../src/list/List.stories.tsx | 29 ++++++++++++++++++ packages/ra-ui-materialui/src/list/List.tsx | 1 + .../ra-ui-materialui/src/list/ListView.tsx | 2 +- 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/List.md b/docs/List.md index d680c9df6db..8f752bcc67f 100644 --- a/docs/List.md +++ b/docs/List.md @@ -55,7 +55,8 @@ You can find more advanced examples of `` usage in the [demos](./Demos.md) | Prop | Required | Type | Default | Description | |---------------------------|----------|----------------|----------------|----------------------------------------------------------------------------------------------| -| `children` | Required | `ReactNode` | - | The components rendering the list of records. | +| `children` | Required if no render | `ReactNode` | - | The components rendering the list of records. | +| `render` | Required if no children | `(listContext) => ReactNode` | - | A function to render the list of records. Receive the list context as its argument | | `actions` | Optional | `ReactElement` | - | The actions to display in the toolbar. | | `aside` | Optional | `ReactElement` | - | The component to display on the side of the list. | | `component` | Optional | `Component` | `Card` | The component to render as the root element. | @@ -79,6 +80,33 @@ You can find more advanced examples of `` usage in the [demos](./Demos.md) Additional props are passed down to the root component (a MUI `` by default). +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for both the list content. +When receiving a render prop the `` component will ignore the children property. + +{% raw %} +```tsx + { + if (isPending) { + return
Loading...
; + } + if (error) { + return
Error: {error.message}
; + } + return ( + record.year} + /> + ); + }} +/> +``` +{% endraw %} + ## `actions` By default, the `` view displays a toolbar on top of the list. It contains: diff --git a/packages/ra-ui-materialui/src/list/List.spec.tsx b/packages/ra-ui-materialui/src/list/List.spec.tsx index a4da84e28df..093de214ff0 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.tsx +++ b/packages/ra-ui-materialui/src/list/List.spec.tsx @@ -23,6 +23,7 @@ import { Default, SelectAllLimit, Themed, + WithRenderProp, } from './List.stories'; const theme = createTheme(defaultTheme); @@ -325,6 +326,19 @@ describe('', () => { }); }); + it('should render a list page using render prop', async () => { + render(); + expect(screen.getByText('Loading...')).toBeDefined(); + + await waitFor(() => { + screen.getByText('1-10 of 13'); + }); + screen.getByText('War and Peace (1869)'); + screen.getByText( + 'A historical novel that intertwines the lives of Russian aristocrats with the events of the Napoleonic wars.' + ); + }); + describe('title', () => { it('should display by default the title of the resource', async () => { render(); diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 9650ef9bf08..a02fc97cffa 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -844,3 +844,32 @@ export const Themed = () => ( ); + +export const WithRenderProp = () => ( + + + ( + { + if (isPending) { + return
Loading...
; + } + if (error) { + return
Error: {error.message}
; + } + return ( + record.year} + /> + ); + }} + /> + )} + /> +
+
+); diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 7e4c0966348..448f35e8329 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -20,6 +20,7 @@ import { Loading } from '../layout'; * - actions * - aside: Side Component * - children: List Layout + * - render: alternative to children Function to render the List Layout, receive the list context as argument * - component * - disableAuthentication * - disableSyncWithLocation diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index ee28b923a1a..7003280cb76 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -195,7 +195,7 @@ export interface ListViewProps { *
* ); */ - children: ReactNode; + children?: ReactNode; /** * A function rendering the list of records. Take the list controller as argument. From 35c57f4cf2f55ec522556107db8e5ad4db05f37a Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 13:58:41 +0200 Subject: [PATCH 03/25] add renderProp on edit component --- docs/Edit.md | 28 +++++++++++++++++++ .../ra-ui-materialui/src/detail/Edit.spec.tsx | 8 ++++++ .../src/detail/Edit.stories.tsx | 21 ++++++++++++++ packages/ra-ui-materialui/src/detail/Edit.tsx | 3 +- .../ra-ui-materialui/src/detail/EditView.tsx | 26 +++++++++++++---- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/docs/Edit.md b/docs/Edit.md index 6fd1b4fbf31..8320635c6ca 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -66,6 +66,7 @@ You can customize the `` component using the following props: * [`actions`](#actions): override the actions toolbar with a custom component * [`aside`](#aside): component to render aside to the main content * `children`: the components that renders the form +* `render`: a function to renders the form, receive the editContext as argument. * `className`: passed to the root component * [`component`](#component): override the root component * [`disableAuthentication`](#disableauthentication): disable the authentication check @@ -80,6 +81,33 @@ You can customize the `` component using the following props: * [`title`](#title): override the page title * [`transform`](#transform): transform the form data before calling `dataProvider.update()` +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for both the list content. +When receiving a render prop the `` component will ignore the children property. + +{% raw %} +```tsx + { + if (isPending) { + return
Loading...
; + } + if (error) { + return
Error: {error.message}
; + } + return ( + record.year} + /> + ); + }} +/> +``` +{% endraw %} + ## `actions` You can replace the list of default actions by your own elements using the `actions` prop: diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx index 58c7fbd5845..007a989f3eb 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx @@ -29,6 +29,7 @@ import { NotificationTranslated, EmptyWhileLoading, Themed, + WithRenderProp, } from './Edit.stories'; describe('', () => { @@ -340,6 +341,13 @@ describe('', () => { }); }); + it('should allow tu use render prop instead of children', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByText('War and Peace')).toHaveLength(1); + }); + }); + describe('onSuccess prop', () => { it('should allow to override the default success side effects', async () => { const dataProvider = { diff --git a/packages/ra-ui-materialui/src/detail/Edit.stories.tsx b/packages/ra-ui-materialui/src/detail/Edit.stories.tsx index 7b891b6d3b4..cf509758f8d 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.stories.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.stories.tsx @@ -397,3 +397,24 @@ export const Themed = () => ( ); + +export const WithRenderProp = () => ( + + + ( + { + return editContext.record ? ( + {editContext.record.title} + ) : null; + }} + > + + + )} + /> + + +); diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index be155172461..6dd40f61b27 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -76,6 +76,7 @@ export const Edit = ( loading = defaultLoading, ...rest } = props; + return ( resource={resource} @@ -95,7 +96,7 @@ export const Edit = ( export interface EditProps extends EditBaseProps, - Omit {} + Omit {} const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 26cbbae0eec..f90d008bb26 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ReactElement, ElementType } from 'react'; +import type { ReactElement, ElementType, ReactNode } from 'react'; import { Card, CardContent, @@ -9,7 +9,11 @@ import { type Theme, } from '@mui/material'; import clsx from 'clsx'; -import { useEditContext, useResourceDefinition } from 'ra-core'; +import { + EditControllerResult, + useEditContext, + useResourceDefinition, +} from 'ra-core'; import { EditActions } from './EditActions'; import { Title } from '../layout'; @@ -22,6 +26,7 @@ export const EditView = (props: EditViewProps) => { actions, aside, children, + render, className, component: Content = Card, emptyWhileLoading = false, @@ -30,11 +35,13 @@ export const EditView = (props: EditViewProps) => { } = props; const { hasShow } = useResourceDefinition(); - const { resource, defaultTitle, record, isPending } = useEditContext(); + const editContext = useEditContext(); + + const { resource, defaultTitle, record, isPending } = editContext; const finalActions = typeof actions === 'undefined' && hasShow ? defaultActions : actions; - if (!children || (!record && isPending && emptyWhileLoading)) { + if ((!children && !render) || (!record && isPending && emptyWhileLoading)) { return null; } @@ -54,7 +61,15 @@ export const EditView = (props: EditViewProps) => { })} > - {record ? children :  } + {record ? ( + render ? ( + render(editContext) + ) : ( + children + ) + ) : ( +   + )} {aside} @@ -70,6 +85,7 @@ export interface EditViewProps emptyWhileLoading?: boolean; title?: string | ReactElement | false; sx?: SxProps; + render?: (editContext: EditControllerResult) => ReactNode; } const PREFIX = 'RaEdit'; From add36d1b055a1432be8039b2f8c33d18dde0b18e Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 14:02:25 +0200 Subject: [PATCH 04/25] fix type on InfiniteList --- packages/ra-ui-materialui/src/list/InfiniteList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx index 20beac31d58..6830d83df9f 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx @@ -108,7 +108,7 @@ const defaultFilter = {}; const defaultLoading = ; export interface InfiniteListProps - extends Omit, 'children'>, + extends Omit, 'children' | 'render'>, ListViewProps {} const PREFIX = 'RaInfiniteList'; From 1b0807020a6ceca5eac8af9198191981c14ea346 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 14:05:35 +0200 Subject: [PATCH 05/25] update Edit comment --- packages/ra-ui-materialui/src/detail/Edit.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index 6dd40f61b27..2d71d031848 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -21,6 +21,8 @@ import { Loading } from '../layout'; * * The component accepts the following props: * + * - children: Component rendering the Form Layout + * - render: Alternative to children. A function to render the Form Layout. Receives the edit context as its argument. * - actions * - aside * - component From e0f44aeb56352bef328611df0bc8143223d40ae4 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 14:45:05 +0200 Subject: [PATCH 06/25] add render prop to Create --- docs/Create.md | 31 ++++++++++++++++ docs/Edit.md | 36 +++++++++++++------ docs/List.md | 2 +- .../src/detail/Create.spec.tsx | 10 +++++- .../src/detail/Create.stories.tsx | 19 ++++++++++ .../ra-ui-materialui/src/detail/Create.tsx | 17 +++++++-- .../src/detail/CreateView.tsx | 15 +++++--- packages/ra-ui-materialui/src/detail/Edit.tsx | 7 +++- .../ra-ui-materialui/src/detail/EditView.tsx | 3 +- packages/ra-ui-materialui/src/list/List.tsx | 6 ++++ 10 files changed, 124 insertions(+), 22 deletions(-) diff --git a/docs/Create.md b/docs/Create.md index 26491acb05e..67428208f6b 100644 --- a/docs/Create.md +++ b/docs/Create.md @@ -58,6 +58,7 @@ You can customize the `` component using the following props: * [`actions`](#actions): override the actions toolbar with a custom component * [`aside`](#aside): component to render aside to the main content * `children`: the components that renders the form +* `render`: Alternative to children. A function that renders the form, receive the create context as its argument * `className`: passed to the root component * [`component`](#component): override the root component * [`disableAuthentication`](#disableauthentication): disable the authentication check @@ -70,6 +71,36 @@ You can customize the `` component using the following props: * [`title`](#title): override the page title * [`transform`](#transform): transform the form data before calling `dataProvider.create()` +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the create context as its argument, allowing to inline the render logic for the create form. +When receiving a render prop the `` component will ignore the children property. + +{% raw %} +```tsx + { + if (createContext.isPending) { + return
Loading...
; + } + if (createContext.error) { + return
Error: {error.message}
; + } + + return ( + +

{`Create new ${createContext.resource}`}

+ + + + + + +
+ ); +}} /> +``` +{% endraw %} + ## `actions` You can replace the list of default actions by your own elements using the `actions` prop: diff --git a/docs/Edit.md b/docs/Edit.md index 8320635c6ca..912d02d2f9a 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -83,25 +83,39 @@ You can customize the `` component using the following props: ## `render` -Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for both the list content. -When receiving a render prop the `` component will ignore the children property. +Alternatively to children you can pass a render prop to ``. The render prop will receive the edit context as its argument, allowing to inline the render logic for the edit form. +When receiving a render prop the `` component will ignore the children property. {% raw %} ```tsx - { - if (isPending) { + { + if (listContext.isPending) { return
Loading...
; } - if (error) { + if (listContext.error) { return
Error: {error.message}
; } return ( - record.year} - /> +
+

{`Edit ${listController.resource} #${listController.record.id}`}

+ + + + + + + + + + + + + + + + +
); }} /> diff --git a/docs/List.md b/docs/List.md index 8f752bcc67f..8daf9dab568 100644 --- a/docs/List.md +++ b/docs/List.md @@ -82,7 +82,7 @@ Additional props are passed down to the root component (a MUI `` by defaul ## `render` -Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for both the list content. +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for the list content. When receiving a render prop the `` component will ignore the children property. {% raw %} diff --git a/packages/ra-ui-materialui/src/detail/Create.spec.tsx b/packages/ra-ui-materialui/src/detail/Create.spec.tsx index abffa876427..e786c710185 100644 --- a/packages/ra-ui-materialui/src/detail/Create.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Create.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import expect from 'expect'; import { CoreAdminContext, testDataProvider } from 'ra-core'; -import { screen, render } from '@testing-library/react'; +import { screen, render, waitFor } from '@testing-library/react'; import { Create } from './Create'; import { @@ -12,6 +12,7 @@ import { NotificationDefault, NotificationTranslated, Themed, + WithRenderProp, } from './Create.stories'; describe('', () => { @@ -57,6 +58,13 @@ describe('', () => { ); }); + it('should support a render prop', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('Create new books')).not.toBeNull(); + }); + }); + describe('title', () => { it('should display by default the title of the resource', async () => { render(); diff --git a/packages/ra-ui-materialui/src/detail/Create.stories.tsx b/packages/ra-ui-materialui/src/detail/Create.stories.tsx index 6285d68b774..bab3d292015 100644 --- a/packages/ra-ui-materialui/src/detail/Create.stories.tsx +++ b/packages/ra-ui-materialui/src/detail/Create.stories.tsx @@ -305,3 +305,22 @@ export const Themed = () => ( ); + +export const WithRenderProp = () => ( + + + ( + { + return ( +
{`Create new ${createContext.resource}`}
+ ); + }} + /> + )} + /> +
+
+); diff --git a/packages/ra-ui-materialui/src/detail/Create.tsx b/packages/ra-ui-materialui/src/detail/Create.tsx index 34468e938f6..0275b462d82 100644 --- a/packages/ra-ui-materialui/src/detail/Create.tsx +++ b/packages/ra-ui-materialui/src/detail/Create.tsx @@ -22,6 +22,8 @@ import { Loading } from '../layout'; * * The component accepts the following props: * + * - children: Component rendering the Form Layout + * - render: Alternative to children. A function to render the Form Layout. Receives the create context as its argument. * - actions * - aside * - component @@ -66,7 +68,6 @@ export const Create = < name: PREFIX, }); - useCheckMinimumRequiredProps('Create', ['children'], props); const { resource, record, @@ -80,6 +81,13 @@ export const Create = < loading = defaultLoading, ...rest } = props; + + if (!props.render && !props.children) { + throw new Error( + ' requires either a `render` prop or `children` prop' + ); + } + return ( resource={resource} @@ -102,8 +110,11 @@ export interface CreateProps< RecordType extends Omit = any, MutationOptionsError = Error, ResultRecordType extends RaRecord = RecordType & { id: Identifier }, -> extends CreateBaseProps, - Omit {} +> extends Omit< + CreateBaseProps, + 'children' | 'render' + >, + CreateViewProps {} const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/detail/CreateView.tsx b/packages/ra-ui-materialui/src/detail/CreateView.tsx index d0ca5b126b1..56ff926e2c7 100644 --- a/packages/ra-ui-materialui/src/detail/CreateView.tsx +++ b/packages/ra-ui-materialui/src/detail/CreateView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ElementType, ReactElement } from 'react'; +import type { ElementType, ReactElement, ReactNode } from 'react'; import { Card, type ComponentsOverrides, @@ -7,7 +7,7 @@ import { type SxProps, type Theme, } from '@mui/material'; -import { useCreateContext } from 'ra-core'; +import { CreateControllerResult, useCreateContext } from 'ra-core'; import clsx from 'clsx'; import { Title } from '../layout'; @@ -18,13 +18,16 @@ export const CreateView = (props: CreateViewProps) => { actions, aside, children, + render, className, component: Content = Card, title, ...rest } = props; - const { resource, defaultTitle } = useCreateContext(); + const createContext = useCreateContext(); + + const { resource, defaultTitle } = createContext; return ( @@ -41,7 +44,9 @@ export const CreateView = (props: CreateViewProps) => { [CreateClasses.noActions]: !actions, })} > - {children} + + {render ? render(createContext) : children} + {aside} @@ -55,6 +60,8 @@ export interface CreateViewProps component?: ElementType; sx?: SxProps; title?: string | ReactElement | false; + render?: (createContext: CreateControllerResult) => ReactNode; + children?: ReactNode; } const PREFIX = 'RaCreate'; diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index 2d71d031848..70a7f587b9f 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -65,7 +65,6 @@ export const Edit = ( name: PREFIX, }); - useCheckMinimumRequiredProps('Edit', ['children'], props); const { resource, id, @@ -79,6 +78,12 @@ export const Edit = ( ...rest } = props; + if (!props.render && !props.children) { + throw new Error( + ' requires either a `render` prop or `children` prop' + ); + } + return ( resource={resource} diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index f90d008bb26..929bc31eaae 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -41,7 +41,8 @@ export const EditView = (props: EditViewProps) => { const finalActions = typeof actions === 'undefined' && hasShow ? defaultActions : actions; - if ((!children && !render) || (!record && isPending && emptyWhileLoading)) { + + if (!record && isPending && emptyWhileLoading) { return null; } diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 448f35e8329..e42d07dfed0 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -80,6 +80,12 @@ export const List = ( name: PREFIX, }); + if (!props.render && !props.children) { + throw new Error( + ' requires either a `render` prop or `children` prop' + ); + } + return ( debounce={debounce} From d45d592240dab96924cc8d4901071d2596f35760 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 14:52:09 +0200 Subject: [PATCH 07/25] add render prop on Show --- .../src/detail/Show.stories.tsx | 19 +++++++++++++++++++ packages/ra-ui-materialui/src/detail/Show.tsx | 8 +++++++- .../ra-ui-materialui/src/detail/ShowView.tsx | 13 ++++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/Show.stories.tsx b/packages/ra-ui-materialui/src/detail/Show.stories.tsx index 46a545f3a74..3fdee242f49 100644 --- a/packages/ra-ui-materialui/src/detail/Show.stories.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.stories.tsx @@ -280,3 +280,22 @@ export const Themed = () => ( ); + +export const WithRenderProp = () => ( + + + ( + + showContext.record ? ( + {showContext.record.title} + ) : null + } + /> + )} + /> + + +); diff --git a/packages/ra-ui-materialui/src/detail/Show.tsx b/packages/ra-ui-materialui/src/detail/Show.tsx index 850bdfe0d2c..6d1755454fc 100644 --- a/packages/ra-ui-materialui/src/detail/Show.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.tsx @@ -75,6 +75,12 @@ export const Show = ( ...rest } = props; + if (!props.render && !props.children) { + throw new Error( + ' requires either a `render` prop or `children` prop' + ); + } + return ( id={id} @@ -90,7 +96,7 @@ export const Show = ( export interface ShowProps extends ShowBaseProps, - Omit {} + Omit {} const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 844f6a1e7cc..6f9d1c3ec60 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ReactElement, ElementType } from 'react'; +import type { ReactElement, ElementType, ReactNode } from 'react'; import { Card, type ComponentsOverrides, @@ -8,7 +8,11 @@ import { type Theme, } from '@mui/material'; import clsx from 'clsx'; -import { useShowContext, useResourceDefinition } from 'ra-core'; +import { + useShowContext, + useResourceDefinition, + ShowControllerResult, +} from 'ra-core'; import { ShowActions } from './ShowActions'; import { Title } from '../layout'; import { ShowProps } from './Show'; @@ -20,6 +24,7 @@ export const ShowView = (props: ShowViewProps) => { actions, aside, children, + render, className, component: Content = Card, emptyWhileLoading = false, @@ -27,7 +32,8 @@ export const ShowView = (props: ShowViewProps) => { ...rest } = props; - const { resource, defaultTitle, record } = useShowContext(); + const showContext = useShowContext(); + const { resource, defaultTitle, record } = showContext; const { hasEdit } = useResourceDefinition(); const finalActions = @@ -66,6 +72,7 @@ export interface ShowViewProps emptyWhileLoading?: boolean; title?: string | ReactElement | false; sx?: SxProps; + render?: (showContext: ShowControllerResult) => ReactNode; } const PREFIX = 'RaShow'; From a94303fe5778c51472bb4f68e5a86ff46f6a05e9 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 15:13:04 +0200 Subject: [PATCH 08/25] add render prop on Show --- docs/Show.md | 3 ++- packages/ra-ui-materialui/src/detail/Edit.spec.tsx | 2 +- packages/ra-ui-materialui/src/detail/Show.spec.tsx | 10 +++++++++- packages/ra-ui-materialui/src/detail/Show.tsx | 2 ++ packages/ra-ui-materialui/src/detail/ShowView.tsx | 6 ++++-- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/Show.md b/docs/Show.md index 580359356a9..850ec020be0 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -61,7 +61,8 @@ That's enough to display the post show view above. | Prop | Required | Type | Default | Description |------------------|----------|-------------------|---------|-------------------------------------------------------- -| `children` | Required | `ReactNode` | | The components rendering the record fields +| `children` | Required if no render | `ReactNode` | | The components rendering the record fields +| `render` | Required if no children | `(showContext) => ReactNode` | | A function rendering the record fields, receive the show context as its argument | `actions` | Optional | `ReactElement` | | The actions to display in the toolbar | `aside` | Optional | `ReactElement` | | The component to display on the side of the list | `className` | Optional | `string` | | passed to the root component diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx index 007a989f3eb..ddfcaf09563 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx @@ -341,7 +341,7 @@ describe('', () => { }); }); - it('should allow tu use render prop instead of children', async () => { + it('should allow to use render prop instead of children', async () => { render(); await waitFor(() => { expect(screen.queryAllByText('War and Peace')).toHaveLength(1); diff --git a/packages/ra-ui-materialui/src/detail/Show.spec.tsx b/packages/ra-ui-materialui/src/detail/Show.spec.tsx index 64c3a0a475f..88db2e52036 100644 --- a/packages/ra-ui-materialui/src/detail/Show.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.spec.tsx @@ -25,6 +25,7 @@ import { TitleFalse, TitleElement, Themed, + WithRenderProp, } from './Show.stories'; import { Show } from './Show'; @@ -132,11 +133,18 @@ describe('', () => { it('should be customized by a theme', async () => { render(); - expect(screen.queryByTestId('themed-view').classList).toContain( + expect(screen.queryByTestId('themed-view')?.classList).toContain( 'custom-class' ); }); + it('should allow to use render prop instead of children', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('War and Peace')).not.toBeNull(); + }); + }); + describe('title', () => { it('should display by default the title of the resource', async () => { render(); diff --git a/packages/ra-ui-materialui/src/detail/Show.tsx b/packages/ra-ui-materialui/src/detail/Show.tsx index 6d1755454fc..9a97860242c 100644 --- a/packages/ra-ui-materialui/src/detail/Show.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.tsx @@ -45,7 +45,9 @@ import { Loading } from '../layout'; * ); * export default App; * + * @typedef {(showContext: Object) => ReactNode} RenderProp * @param {ShowProps} inProps + * @param {RenderProp} inProps.render A function rendering the page content, receive the show context as its argument. * @param {ReactElement|false} inProps.actions An element to display above the page content, or false to disable actions. * @param {string} inProps.className A className to apply to the page content. * @param {ElementType} inProps.component The component to use as root component (div by default). diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 6f9d1c3ec60..5028186e685 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -39,7 +39,7 @@ export const ShowView = (props: ShowViewProps) => { const finalActions = typeof actions === 'undefined' && hasEdit ? defaultActions : actions; - if (!children || (!record && emptyWhileLoading)) { + if (!record && emptyWhileLoading) { return null; } return ( @@ -57,7 +57,9 @@ export const ShowView = (props: ShowViewProps) => { [ShowClasses.noActions]: !finalActions, })} > - {children} + + {render ? render(showContext) : children} + {aside} From d6d0b10461fe0452474de53c8d9e52008549b2db Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 15:41:26 +0200 Subject: [PATCH 09/25] add render prop on InfiniteList --- docs/InfiniteList.md | 32 +++++++++++++- .../src/list/InfiniteList.spec.tsx | 28 +++++++++++- .../src/list/InfiniteList.stories.tsx | 44 +++++++++++++++++++ .../src/list/InfiniteList.tsx | 6 +++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/docs/InfiniteList.md b/docs/InfiniteList.md index be70505ea75..671245769b3 100644 --- a/docs/InfiniteList.md +++ b/docs/InfiniteList.md @@ -58,9 +58,10 @@ The props are the same as [the `` component](./List.md): | Prop | Required | Type | Default | Description | |----------------------------|----------|----------------|-------------------------|----------------------------------------------------------------------------------------------| -| `children` | Required | `ReactNode` | - | The component to use to render the list of records. | +| `children` | Required if no render | `ReactNode` | - | The component to use to render the list of records. | +| `render` | Required if no children | `ReactNode` | - | A function that render the list of records, receives the list context as argument. | | `actions` | Optional | `ReactElement` | - | The actions to display in the toolbar. | -| `aside` | Optional | `ReactElement` | - | The component to display on the side of the list. | +| `aside` | Optional | `(listContext) => ReactElement` | - | The component to display on the side of the list. | | `component` | Optional | `Component` | `Card` | The component to render as the root element. | | `debounce` | Optional | `number` | `500` | The debounce delay in milliseconds to apply when users change the sort or filter parameters. | | `disable Authentication` | Optional | `boolean` | `false` | Set to `true` to disable the authentication check. | @@ -84,6 +85,33 @@ Check the [`` component](./List.md) for details about each prop. Additional props are passed down to the root component (a MUI `` by default). +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for the list content. +When receiving a render prop the `` component will ignore the children property. + +{% raw %} +```tsx + { + if (isPending) { + return
Loading...
; + } + if (error) { + return
Error: {error.message}
; + } + return ( + record.year} + /> + ); + }} +/> +``` +{% endraw %} + ## `pagination` You can replace the default "load on scroll" pagination (triggered by a component named ``) by a custom pagination component. To get the pagination state and callbacks, you'll need to read the `InfinitePaginationContext`. diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.spec.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.spec.tsx index acece3dd609..fcd310d2d80 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.spec.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.spec.tsx @@ -1,14 +1,38 @@ import * as React from 'react'; import expect from 'expect'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; -import { Themed } from './InfiniteList.stories'; +import { Themed, WithRenderProp } from './InfiniteList.stories'; describe('', () => { + let originalIntersectionObserver; + beforeAll(() => { + originalIntersectionObserver = window.IntersectionObserver; + const intersectionObserverMock = () => ({ + observe: () => null, + unobserve: () => null, + }); + window.IntersectionObserver = jest + .fn() + .mockImplementation(intersectionObserverMock); + }); + afterAll(() => { + window.IntersectionObserver = originalIntersectionObserver; + }); + it('should be customized by a theme', async () => { render(); expect(screen.queryByTestId('themed-list').classList).toContain( 'custom-class' ); }); + it('should render a list page using render prop', async () => { + render(); + expect(screen.getByText('Loading...')).toBeDefined(); + + await waitFor(() => { + screen.getByText('War and Peace'); + screen.getByText('Leo Tolstoy'); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx index 4b638d80e0e..7aeba9b2dd1 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx @@ -478,3 +478,47 @@ export const Themed = () => ( /> ); + +export const WithRenderProp = () => ( + + dataProvider + .getList(resource, params) + .then(({ data, total }) => ({ + data, + pageInfo: { + hasNextPage: + total! > + params.pagination.page * + params.pagination.perPage, + hasPreviousPage: params.pagination.page > 1, + }, + })), + }} + > + ( + { + if (isPending) { + return
Loading...
; + } + if (error) { + return
Error: {error.message}
; + } + return ( + record.year} + /> + ); + }} + /> + )} + /> +
+); diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx index 6830d83df9f..54248d269e7 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx @@ -83,6 +83,12 @@ export const InfiniteList = ( name: PREFIX, }); + if (!props.render && !props.children) { + throw new Error( + ' requires either a `render` prop or `children` prop' + ); + } + return ( debounce={debounce} From 36a20c6d626f9c366824acf2382259293778f28c Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 17:27:01 +0200 Subject: [PATCH 10/25] add render prop on ReferenceField --- docs/ReferenceField.md | 24 +++++++++++ .../src/field/ReferenceField.spec.tsx | 40 +++++++++++++++++++ .../src/field/ReferenceField.stories.tsx | 20 ++++++++++ .../src/field/ReferenceField.tsx | 33 +++++++++++---- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index 256c1174919..6f2818fa431 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -75,6 +75,7 @@ It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for perform | `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` | Optional | `ReactNode` | - | One or more Field elements used to render the referenced record | +| `render` | Optional | (referenceFieldContext) => `ReactNode` | - | A function used to render the referenced record, receive the reference field context as its argument | | `empty` | Optional | `ReactNode` | - | What to render 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. | @@ -83,6 +84,29 @@ It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for perform `` also accepts the [common field props](./Fields.md#common-field-props). +## `render` + +Alternatively you can pass a render prop instead of children to be able to inline the rendering. The render function will then receive the reference field context directly. + +```jsx +export const MyReferenceField = () => ( + { + if (isPending) { + return

Loading...

; + } + + if (error) { + return ( +

+ {error.message} +

+ ); + } + return

{referenceRecord.name}

; + }} /> +); +``` + ## `empty` `` can display a custom message when the referenced record is missing, thanks to the `empty` prop. diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx index 00e483d2c88..59a60eeee5f 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx @@ -506,6 +506,46 @@ describe('', () => { await screen.findByText('boo'); }); + it('should render its child using render prop when given', async () => { + const dataProvider = testDataProvider({ + getMany: jest.fn().mockResolvedValue({ + data: [{ id: 123, title: 'foo' }], + }), + }); + render( + + + + + + referenceRecord?.title || 'No title' + } + /> + + + + + ); + 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' + ); + }); + describe('link', () => { it('should render a link to specified link type', async () => { render(); diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx index 8d013fe7b4f..36196f3df14 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx @@ -951,3 +951,23 @@ export const Themed = () => { ); }; + +export const WithRenderProp = () => ( + + { + console.log({ error, isPending, referenceRecord }); + if (isPending) { + return

Loading...

; + } + + if (error) { + return

{error.message}

; + } + return referenceRecord.ISBN; + }} + >
+
+); diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 2afe09b9e8d..1f256a694f0 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -16,6 +16,7 @@ import { type RaRecord, ReferenceFieldBase, useReferenceFieldContext, + UseReferenceFieldControllerResult, } from 'ra-core'; import type { UseQueryOptions } from '@tanstack/react-query'; import clsx from 'clsx'; @@ -40,6 +41,14 @@ import { visuallyHidden } from '@mui/utils'; * *
* + * @example // using a render prop to render the record + * ( + *

{context.referenceRecord?.name}

+ * ) + * }> + *
+ * * @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. @@ -60,9 +69,11 @@ import { visuallyHidden } from '@mui/utils'; export const ReferenceField = < RecordType extends Record = Record, ReferenceRecordType extends RaRecord = RaRecord, ->( - inProps: ReferenceFieldProps -) => { +>({ + children, + render, + ...inProps +}: ReferenceFieldProps) => { const props = useThemeProps({ props: inProps, name: PREFIX, @@ -89,6 +100,8 @@ export const ReferenceField = < > {...props} + render={render} + children={children} /> ); @@ -99,6 +112,9 @@ export interface ReferenceFieldProps< ReferenceRecordType extends RaRecord = RaRecord, > extends FieldProps { children?: ReactNode; + render?: ( + context: UseReferenceFieldControllerResult + ) => ReactNode; /** * @deprecated Use the empty prop instead */ @@ -123,13 +139,13 @@ export const ReferenceFieldView = < >( props: ReferenceFieldViewProps ) => { - const { children, className, emptyText, reference, sx, ...rest } = + const { children, render, className, emptyText, reference, sx, ...rest } = useThemeProps({ props: props, name: PREFIX, }); - const { error, link, isLoading, referenceRecord } = - useReferenceFieldContext(); + const referenceFieldContext = useReferenceFieldContext(); + const { error, link, isLoading, referenceRecord } = referenceFieldContext; const getRecordRepresentation = useGetRecordRepresentation(reference); @@ -150,7 +166,7 @@ export const ReferenceFieldView = < return ; } - const child = children || ( + const child = (render ? render(referenceFieldContext) : children) || ( {getRecordRepresentation(referenceRecord)} @@ -192,6 +208,9 @@ export interface ReferenceFieldViewProps< > extends FieldProps, Omit, 'link'> { children?: ReactNode; + render?: ( + context: UseReferenceFieldControllerResult + ) => ReactNode; reference: string; resource?: string; translateChoice?: Function | boolean; From 8398c08efeadf2db327c939abd287982c43914c1 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 17:59:15 +0200 Subject: [PATCH 11/25] document and test render prop on ReferenceManyField --- docs/ReferenceManyField.md | 68 ++++++++++++++++++- .../src/field/ReferenceManyField.spec.tsx | 15 ++++ .../src/field/ReferenceManyField.stories.tsx | 27 ++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/docs/ReferenceManyField.md b/docs/ReferenceManyField.md index 15f6257cafd..a6a5f25c6be 100644 --- a/docs/ReferenceManyField.md +++ b/docs/ReferenceManyField.md @@ -89,7 +89,8 @@ This example leverages [``](./SingleFieldList.md) to display an | Prop | Required | Type | Default | Description | | -------------- | -------- | --------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------- | -| `children` | Required | `Element` | - | One or several elements that render a list of records based on a `ListContext` | +| `children` | Required if no render | `Element` | - | One or several elements that render a list of records based on a `ListContext` | +| `render` | Required if no children | `(listContext) => Element` | - | Function that receives a `ListContext` and render elements | | `debounce` | Optional | `number` | 500 | debounce time in ms for the `setFilters` callbacks | | `empty` | Optional | `ReactNode` | - | Element to display when there are no related records. | | `filter` | Optional | `Object` | - | Filters to use when fetching the related records, passed to `getManyReference()` | @@ -163,6 +164,71 @@ export const AuthorShow = () => ( ); ``` +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic for both the list and the pagination. +When receiving a render prop the `` component will ignore the children and the pagination property. + +```jsx +import { Show, SimpleShowLayout, ReferenceManyField, DataTable, TextField, DateField } from 'react-admin'; + +const CustomAuthorView = ({ + source, + children, +}: { + source: string; +}) => { + const context = useListController(); + + if (context.isPending) { + return

Loading...

; + } + + if (context.error) { + return

{context.error.toString()}

; + } + return ( +

+ {listContext.data?.map((datum, index) => ( +

  • {datum[source]}
  • + ))} +

    + ); +}; + +const AuthorShow = () => ( + + + + + { + + if (context.isPending) { + return

    Loading...

    ; + } + + if (context.error) { + return

    {context.error.toString()}

    ; + } + return ( +

    + {listContext.data?.map((author, index) => ( +

  • {author.name}
  • + ))} +

    + ); + } + } + /> +
    +
    +); +``` + ## `debounce` By default, `` does not refresh the data as soon as the user enters data in the filter form. Instead, it waits for half a second of user inactivity (via `lodash.debounce`) before calling the `dataProvider` on filter change. This is to prevent repeated (and useless) calls to the API. diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyField.spec.tsx index db966fcb3f0..1f9c052e586 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyField.spec.tsx @@ -14,6 +14,7 @@ import { Empty, WithPagination, WithPaginationAndSelectAllLimit, + WithRenderProp, } from './ReferenceManyField.stories'; const theme = createTheme(); @@ -206,6 +207,20 @@ describe('', () => { }); }); + it('should use render prop when provides', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('progressbar')).toHaveLength(0); + }); + const items = await screen.findAllByRole('listitem'); + expect(items).toHaveLength(5); + expect(items[0].textContent).toEqual('War and Peace'); + expect(items[1].textContent).toEqual('Anna Karenina'); + expect(items[2].textContent).toEqual('Resurrection'); + expect(items[3].textContent).toEqual('The Idiot'); + expect(items[4].textContent).toEqual('The Last Day of a Condemned'); + }); + describe('pagination', () => { it('should render pagination based on total from getManyReference', async () => { const data = [ diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx index 9b45707ea22..eadc8c64fd1 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx @@ -286,3 +286,30 @@ export const FullApp = () => ( ); + +export const WithRenderProp = () => ( + + { + if (isPending) { + return

    Loading...

    ; + } + + if (error) { + return

    {error.message}

    ; + } + return ( + <> + {data?.map((datum, index) => ( +
  • + {datum.title} +
  • + ))} + + ); + }} + /> +
    +); From 90b5ca655ed3f24b9f27fef37a0b6e2cc054c4ac Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Tue, 15 Jul 2025 18:14:19 +0200 Subject: [PATCH 12/25] add render prop on ReferenceArrayField (remove pagination from ReferenceArrayFieldBase) --- docs/ReferenceArrayField.md | 33 +++++++++++++++++ docs/ReferenceArrayFieldBase.md | 1 + .../src/field/ReferenceArrayField.spec.tsx | 15 ++++++++ .../src/field/ReferenceArrayField.stories.tsx | 37 +++++++++++++++++++ .../src/field/ReferenceArrayField.tsx | 17 +++++++-- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/ReferenceArrayField.md b/docs/ReferenceArrayField.md index c9f1a789a76..fa1494d4d5a 100644 --- a/docs/ReferenceArrayField.md +++ b/docs/ReferenceArrayField.md @@ -86,6 +86,7 @@ You can change how the list of related records is rendered by passing a custom c | `source` | Required | `string` | - | Name of the property to display | | `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'tags' | | `children` | Optional | `Element` | `` | One or several elements that render a list of records based on a `ListContext` | +| `render` | Optional | `(listContext) => Element` | `` | A function that takes a list context and render a list of records | | `filter` | Optional | `Object` | - | Filters to use when fetching the related records (the filtering is done client-side) | | `pagination` | Optional | `Element` | - | Pagination element to display pagination controls. empty by default (no pagination) | | `perPage` | Optional | `number` | 1000 | Maximum number of results to display | @@ -179,6 +180,38 @@ export const PostShow = () => ( ); ``` + +## `render` + +Alternatively to children you can pass a render prop to ``. The render prop will receive the list context as its argument, allowing to inline the render logic . +When receiving a render prop the `` component will ignore the children property. + + +```jsx + { + + if (context.isPending) { + return

    Loading...

    ; + } + + if (context.error) { + return

    {context.error.toString()}

    ; + } + return ( +

    + {listContext.data?.map((tag, index) => ( +

  • {tag.name}
  • + ))} +

    + ); + }} +/> +``` + ## `filter` `` fetches all the related records, and displays them all, too. You can use the `filter` prop to filter the list of related records to display (this works by filtering the records client-side, after the fetch). diff --git a/docs/ReferenceArrayFieldBase.md b/docs/ReferenceArrayFieldBase.md index 789def03769..4323f4bcfc9 100644 --- a/docs/ReferenceArrayFieldBase.md +++ b/docs/ReferenceArrayFieldBase.md @@ -135,6 +135,7 @@ const MyTagList = (props: { children: React.ReactNode }) => { Alternatively to children you can pass a `render` function prop to ``. The `render` prop will receive the `ListContext` as its argument, allowing to inline the `render` logic. When receiving a `render` prop the `` component will ignore the children property. + ```jsx ', () => { expect(await screen.findByText('artist_3')).not.toBeNull(); }); + it('should support renderProp', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('John Lennon')).not.toBeNull(); + expect(screen.queryByText('Paul McCartney')).not.toBeNull(); + expect(screen.queryByText('Ringo Star')).not.toBeNull(); + expect(screen.queryByText('George Harrison')).not.toBeNull(); + expect(screen.queryByText('Mick Jagger')).not.toBeNull(); + expect(screen.queryByText('Keith Richards')).not.toBeNull(); + expect(screen.queryByText('Ronnie Wood')).not.toBeNull(); + expect(screen.queryByText('Charlie Watts')).not.toBeNull(); + }); + }); + describe('"Select all" button', () => { it('should be displayed if an item is selected', async () => { render(); diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx index 80c346f2a28..41bc49c7fd2 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx @@ -164,3 +164,40 @@ export const WithPagination = () => ( ); + +export const WithRenderProp = () => ( + + + + + + { + if (isPending) { + return

    Loading...

    ; + } + + if (error) { + return ( +

    + {error.toString()} +

    + ); + } + + return ( +

    + {data?.map((datum, index) => ( +

  • {datum.name}
  • + ))} +

    + ); + }} + /> +
    +
    +
    +
    +); diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx index 6cc9b9d1d8d..8d8e5634e58 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx @@ -80,6 +80,7 @@ export const ReferenceArrayField = < ReferenceRecordType extends RaRecord = RaRecord, >({ pagination, + render, ...inProps }: ReferenceArrayFieldProps) => { const props = useThemeProps({ @@ -88,7 +89,11 @@ export const ReferenceArrayField = < }); return ( - + ); }; @@ -110,8 +115,10 @@ export interface ReferenceArrayFieldViewProps export const ReferenceArrayFieldView = ( props: ReferenceArrayFieldViewProps ) => { - const { children, pagination, className, sx } = props; - const { isPending, total } = useListContext(); + const { children, render, pagination, className, sx } = props; + const listContext = useListContext(); + + const { isPending, total } = listContext; return ( @@ -121,7 +128,9 @@ export const ReferenceArrayFieldView = ( /> ) : ( - {children || } + {(render ? render(listContext) : children) || ( + + )} {pagination && total !== undefined ? pagination : null} )} From 5add7d56b408fd47bff20376582afc764ecb5825 Mon Sep 17 00:00:00 2001 From: ThieryMichel Date: Thu, 17 Jul 2025 15:07:26 +0200 Subject: [PATCH 13/25] add render prop to ReferenceOneField --- docs/ReferenceOneField.md | 29 +++++++++++++++++++ .../src/field/ReferenceOneField.spec.tsx | 6 ++++ .../src/field/ReferenceOneField.stories.tsx | 22 ++++++++++++++ .../src/field/ReferenceOneField.tsx | 3 ++ 4 files changed, 60 insertions(+) diff --git a/docs/ReferenceOneField.md b/docs/ReferenceOneField.md index a09fc1e5a41..ec574a50d24 100644 --- a/docs/ReferenceOneField.md +++ b/docs/ReferenceOneField.md @@ -60,6 +60,7 @@ const BookShow = () => ( | `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' | | `children` | Optional | `Element` | - | The Field element used to render the referenced record | +| `render` | Optional | `(ReferenceFieldContext) => Element` | - | A function that takes the `ReferenceFieldContext` and returns a React element | | `empty` | Optional | `ReactNode` | - | The text or element to display when the referenced record is empty | | `filter` | Optional | `Object` | `{}` | Used to filter referenced records | | `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | @@ -80,6 +81,34 @@ For instance, if you want to render both the genre and the ISBN for a book: ``` + +## `render` + +Alternatively to children you can pass a `render` function prop to ``. The `render` function prop will receive the `ReferenceFieldContext` as its argument, allowing to inline the render logic. +When receiving a `render` function prop the `` component will ignore the children property. + +```jsx + { + if (isPending) { + return Loading...; + } + + if (error) { + return {error.toString()}; + } + return ( +
    + {referenceRecord ? referenceRecord.genre : ''} + {referenceRecord ? referenceRecord.ISBN : ''} +
    + ); + }} +/> +``` + ## `empty` Use `empty` to customize the text displayed when the related record is empty. diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx index ee10bc6eb60..fe863bac4e3 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.spec.tsx @@ -9,6 +9,7 @@ import { EmptyText, Empty, Themed, + WithRenderProp, } from './ReferenceOneField.stories'; describe('ReferenceOneField', () => { @@ -54,6 +55,11 @@ describe('ReferenceOneField', () => { }); }); + it('should allow to render the referenceRecord using a render prop', async () => { + render(); + await screen.findByText('9780393966473'); + }); + describe('emptyText', () => { it('should render the emptyText prop when the record is not found', async () => { render(); diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx index 0db123a2b9b..bc988013257 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.stories.tsx @@ -568,3 +568,25 @@ export const Themed = () => (
    ); + +export const WithRenderProp = () => ( + + { + if (isPending) { + return

    Loading...

    ; + } + + if (error) { + return

    {error.toString()}

    ; + } + + return ( + {referenceRecord ? referenceRecord.ISBN : ''} + ); + }} + /> +
    +); diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index 768da81f09a..4102eaf6490 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -7,6 +7,7 @@ import { SortPayload, RaRecord, ReferenceOneFieldBase, + UseReferenceResult, } from 'ra-core'; import { FieldProps } from './types'; @@ -37,6 +38,7 @@ export const ReferenceOneField = < const { children, + render, reference, source = 'id', target, @@ -88,6 +90,7 @@ export interface ReferenceOneFieldProps< ReferenceRecordType extends RaRecord = RaRecord, > extends Omit, 'source' | 'emptyText'> { children?: ReactNode; + render?: (record: UseReferenceResult) => ReactElement; reference: string; target: string; sort?: SortPayload; From 1a873b42af784a1e04030077578017afe3931369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Mon, 21 Jul 2025 20:16:43 +0200 Subject: [PATCH 14/25] [doc] Improve Create page --- docs/Create.md | 123 ++++++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 48 deletions(-) diff --git a/docs/Create.md b/docs/Create.md index 67428208f6b..55cb5f48a5a 100644 --- a/docs/Create.md +++ b/docs/Create.md @@ -55,51 +55,25 @@ export default App; You can customize the `` component using the following props: -* [`actions`](#actions): override the actions toolbar with a custom component -* [`aside`](#aside): component to render aside to the main content -* `children`: the components that renders the form -* `render`: Alternative to children. A function that renders the form, receive the create context as its argument -* `className`: passed to the root component -* [`component`](#component): override the root component -* [`disableAuthentication`](#disableauthentication): disable the authentication check -* [`mutationMode`](#mutationmode): switch to optimistic or undoable mutations (pessimistic by default) -* [`mutationOptions`](#mutationoptions): options for the `dataProvider.create()` call -* [`record`](#record): initialize the form with a record -* [`redirect`](#redirect): change the redirect location after successful creation -* [`resource`](#resource): override the name of the resource to create -* [`sx`](#sx-css-api): Override the styles -* [`title`](#title): override the page title -* [`transform`](#transform): transform the form data before calling `dataProvider.create()` - -## `render` - -Alternatively to children you can pass a render prop to ``. The render prop will receive the create context as its argument, allowing to inline the render logic for the create form. -When receiving a render prop the `` component will ignore the children property. - -{% raw %} -```tsx - { - if (createContext.isPending) { - return
    Loading...
    ; - } - if (createContext.error) { - return
    Error: {error.message}
    ; - } - - return ( - -

    {`Create new ${createContext.resource}`}

    - - - - - - -
    - ); -}} /> -``` -{% endraw %} +| Prop | Required | Type | Default | Description | +|---------------------|----------|---------------------|----------------|--------------------------------------------------------------------------------------------------| +| `children` | Optional* | `ReactNode` | - | The components that render the form | +| `render` | Optional* | `function` | - | Alternative to children. Function that renders the form, receives the create context as argument | +| `actions` | Optional | `ReactNode` | Default toolbar| Override the actions toolbar with a custom component | +| `aside` | Optional | `ReactNode` | - | Component to render aside to the main content | +| `className` | Optional | `string` | - | Passed to the root component | +| `component` | Optional | `string`/`Component`| `Card` | Override the root component | +| `disableAuthentication` | Optional | `boolean` | `false` | Disable the authentication check | +| `mutationMode` | Optional | `string` | `pessimistic` | Switch to optimistic or undoable mutations | +| `mutationOptions` | Optional | `object` | - | Options for the `dataProvider.create()` call | +| `record` | Optional | `object` | `{}` | Initialize the form with a record | +| `redirect` | Optional | `string`/`function` | `'edit'` | Change the redirect location after successful creation | +| `resource` | Optional | `string` | From URL | Override the name of the resource to create | +| `sx` | Optional | `object` | - | Override the styles | +| `title` | Optional | `string`/`ReactNode`| Translation | Override the page title | +| `transform` | Optional | `function` | - | Transform the form data before calling `dataProvider.create()` | + +* You must provide either `children` or `render`. ## `actions` @@ -151,6 +125,28 @@ const PostCreate = () => ( {% endraw %} +## `children` + +The `` component will render its children inside a `CreateContext` provider, which the `save` function. Children can be any React node, but are usually a form component like [``](./SimpleForm.md), [``](./TabbedForm.md), or the headless [`
    `](./Form.md) component. + +```tsx +import { Create, SimpleForm, TextInput, DateInput, required } from 'react-admin'; +import RichTextInput from 'ra-input-rich-text'; + +export const PostCreate = () => ( + + + + + + + + +); +``` + +**Tip**: Alternatively to `children`, you can pass a [`render`](#render) prop to ``. + ## `component` By default, the `` view render the main form inside a Material UI `` element. The actual layout of the form depends on the `Form` component you're using ([``](./SimpleForm.md), [``](./TabbedForm.md), or a custom form component). @@ -191,9 +187,9 @@ const PostCreate = () => ( The `` view exposes a Save button, which perform a "mutation" (i.e. it creates the data). React-admin offers three modes for mutations. The mode determines when the side effects (redirection, notifications, etc.) are executed: -- `pessimistic` (default): The mutation is passed to the dataProvider first. When the dataProvider returns successfully, the mutation is applied locally, and the side effects are executed. -- `optimistic`: The mutation is applied locally and the side effects are executed immediately. Then the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. -- `undoable`: The mutation is applied locally and the side effects are executed immediately. Then a notification is shown with an undo button. If the user clicks on undo, the mutation is never sent to the dataProvider, and the page is refreshed. Otherwise, after a 5 seconds delay, the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. +* `pessimistic` (default): The mutation is passed to the dataProvider first. When the dataProvider returns successfully, the mutation is applied locally, and the side effects are executed. +* `optimistic`: The mutation is applied locally and the side effects are executed immediately. Then the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. +* `undoable`: The mutation is applied locally and the side effects are executed immediately. Then a notification is shown with an undo button. If the user clicks on undo, the mutation is never sent to the dataProvider, and the page is refreshed. Otherwise, after a 5 seconds delay, the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. By default, pages using `` use the `pessimistic` mutation mode as the new record identifier is often generated on the backend. However, should you decide to generate this identifier client side, you can change the `mutationMode` to either `optimistic` or `undoable`: @@ -346,6 +342,37 @@ Note that the `redirect` prop is ignored if you set [the `mutationOptions` prop] If you want to allow the user to enter several records one after the other, setting `redirect` to `false` won't make it, as the form isn't emptied by default. You'll have to empty the form using the `mutationOptions`, and this option disables the `redirect` prop. Check [the Save And Add Another section](#save-and-add-another) for more details. +## `render` + +Alternatively to `children`, you can pass a `render` prop to ``. It will receive the `CreateContext` as its argument, and should return a React node. + +This allows to inline the render logic for the create page. + +{% raw %} + +```tsx +const PostCreate = () => () + ( +
    +

    Create new Post

    + + +