diff --git a/docs/Create.md b/docs/Create.md
index 26491acb05e..53df4f61c64 100644
--- a/docs/Create.md
+++ b/docs/Create.md
@@ -55,20 +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
-* `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()`
+| 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`
@@ -120,6 +125,28 @@ const PostCreate = () => (
{% endraw %}
+## `children`
+
+The `` component will render its children inside a [`CreateContext`](./useCreateContext.md#return-value). Children can be any React node, but are usually a form component like [``](./SimpleForm.md), [``](./TabbedForm.md), or the headless [`
+ );
+ }}
+ />
+
+
+
+
+);
diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx
index bbf45fed5a3..962e95016b0 100644
--- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx
+++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx
@@ -85,13 +85,15 @@ export const ReferenceArrayField = <
props: inProps,
name: PREFIX,
});
- const { pagination, children, className, sx, ...controllerProps } = props;
+ const { pagination, children, className, sx, render, ...controllerProps } =
+ props;
return (
{children}
@@ -110,6 +112,7 @@ export interface ReferenceArrayFieldProps<
export interface ReferenceArrayFieldViewProps {
pagination?: React.ReactElement;
children?: React.ReactNode;
+ render?: (props: ListControllerProps) => React.ReactNode;
className?: string;
sx?: SxProps;
}
@@ -117,8 +120,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 (
@@ -128,7 +133,9 @@ export const ReferenceArrayFieldView = (
/>
) : (
- {children || }
+ {(render ? render(listContext) : children) || (
+
+ )}
{pagination && total !== undefined ? pagination : null}
)}
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..7dc22b926e2 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.
@@ -67,12 +76,12 @@ export const ReferenceField = <
props: inProps,
name: PREFIX,
});
- const { emptyText, empty } = props;
+ const { children, render, emptyText, empty, ...rest } = props;
const translate = useTranslate();
return (
- {...props}
+ {...rest}
empty={
emptyText ? (
@@ -88,8 +97,11 @@ export const ReferenceField = <
}
>
- {...props}
- />
+ {...rest}
+ render={render}
+ >
+ {children}
+
);
};
@@ -99,6 +111,9 @@ export interface ReferenceFieldProps<
ReferenceRecordType extends RaRecord = RaRecord,
> extends FieldProps {
children?: ReactNode;
+ render?: (
+ context: UseReferenceFieldControllerResult
+ ) => ReactNode;
/**
* @deprecated Use the empty prop instead
*/
@@ -123,13 +138,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 +165,7 @@ export const ReferenceFieldView = <
return ;
}
- const child = children || (
+ const child = (render ? render(referenceFieldContext) : children) || (
{getRecordRepresentation(referenceRecord)}
@@ -192,6 +207,9 @@ export interface ReferenceFieldViewProps<
> extends FieldProps,
Omit, 'link'> {
children?: ReactNode;
+ render?: (
+ context: UseReferenceFieldControllerResult
+ ) => ReactNode;
reference: string;
resource?: string;
translateChoice?: Function | boolean;
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}
+
+ ))}
+ >
+ );
+ }}
+ />
+
+);
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..438c27ea228 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,
@@ -75,6 +77,7 @@ export const ReferenceOneField = <
{children}
@@ -88,6 +91,7 @@ export interface ReferenceOneFieldProps<
ReferenceRecordType extends RaRecord = RaRecord,
> extends Omit, 'source' | 'emptyText'> {
children?: ReactNode;
+ render?: (record: UseReferenceResult) => ReactElement;
reference: string;
target: string;
sort?: SortPayload;
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 20beac31d58..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}
@@ -108,7 +114,7 @@ const defaultFilter = {};
const defaultLoading = ;
export interface InfiniteListProps
- extends Omit, 'children'>,
+ extends Omit, 'children' | 'render'>,
ListViewProps {}
const PREFIX = 'RaInfiniteList';
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 6858879fd97..0674c13052f 100644
--- a/packages/ra-ui-materialui/src/list/List.stories.tsx
+++ b/packages/ra-ui-materialui/src/list/List.stories.tsx
@@ -888,3 +888,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 23b980e1c81..e42d07dfed0 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
@@ -72,12 +73,19 @@ export const List = (
resource,
sort,
storeKey,
+ render,
...rest
} = useThemeProps({
props: props,
name: PREFIX,
});
+ if (!props.render && !props.children) {
+ throw new Error(
+ ' requires either a `render` prop or `children` prop'
+ );
+ }
+
return (
debounce={debounce}
@@ -93,13 +101,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..7003280cb76 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.
*
@@ -191,7 +195,31 @@ export interface ListViewProps {
*
* );
*/
- children: ReactNode;
+ 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 .