diff --git a/docs/CreateBase.md b/docs/CreateBase.md index 6edae3d4d9d..c7a4f393237 100644 --- a/docs/CreateBase.md +++ b/docs/CreateBase.md @@ -46,6 +46,7 @@ export const BookCreate = () => ( You can customize the `` component using the following props, documented in the `` component: * `children`: the components that renders the form +* `render`: alternative to children, a function that takes the `CreateController` context and renders the form * [`disableAuthentication`](./Create.md#disableauthentication): disable the authentication check * [`mutationMode`](./Create.md#mutationmode): Switch to optimistic or undoable mutations (pessimistic by default) * [`mutationOptions`](./Create.md#mutationoptions): options for the `dataProvider.create()` call diff --git a/docs/EditBase.md b/docs/EditBase.md index 0d820c6a258..a5671f6f1f1 100644 --- a/docs/EditBase.md +++ b/docs/EditBase.md @@ -47,6 +47,7 @@ export const BookEdit = () => ( You can customize the `` component using the following props, documented in the `` component: * `children`: the components that renders the form +* `render`: alternative to children, a function that takes the `EditController` context and renders the form * [`disableAuthentication`](./Edit.md#disableauthentication): disable the authentication check * [`id`](./Edit.md#id): the id of the record to edit * [`mutationMode`](./Edit.md#mutationmode): switch to optimistic or pessimistic mutations (undoable by default) diff --git a/docs/ListBase.md b/docs/ListBase.md index 05770584c4e..b23d972f27e 100644 --- a/docs/ListBase.md +++ b/docs/ListBase.md @@ -6,7 +6,7 @@ storybook_path: ra-core-controller-list-listbase--no-auth-provider # `` -`` is a headless variant of [``](./List.md). It fetches a list of records from the data provider, puts it in a [`ListContext`](./useListContext.md), and renders its children. Use it to build a custom list layout. +`` is a headless List page component. It fetches a list of records from the data provider, puts it in a [`ListContext`](./useListContext.md), and renders its children. Use it to build a custom list layout. Contrary to [``](./List.md), it does not render the page layout, so no title, no actions, no ``, and no pagination. @@ -14,47 +14,73 @@ Contrary to [``](./List.md), it does not render the page layout, so no tit ## Usage -You can use `ListBase` to create your own custom reusable List component, like this one: +You can use `ListBase` to create your own custom List page component, like this one: ```jsx import { + DataTable, ListBase, - Title, ListToolbar, - Pagination, DataTable, + Pagination, + Title, } from 'react-admin'; import { Card } from '@mui/material'; -const MyList = ({ children, actions, filters, title, ...props }) => ( - - +const PostList = () => ( + <ListBase> + <Title title="Post List"/> <ListToolbar - filters={filters} - actions={actions} + filters={[ + { source: 'q', label: 'Search', alwaysOn: true }, + { source: 'published', label: 'Published', type: 'boolean' }, + ]} /> <Card> - {children} + <DataTable> + <DataTable.Col source="title" /> + <DataTable.Col source="author" /> + <DataTable.Col source="published_at" /> + </DataTable> </Card> <Pagination /> </ListBase> ); +``` + +Alternatively, you can pass a `render` function prop instead of `children`. This function will receive the `ListContext` as argument. +```jsx const PostList = () => ( - <MyList title="Post List"> - <DataTable> - ... - </DataTable> - </MyList> + <ListBase render={({ data, total, isPending, error }) => ( + <Card> + <Title title="Post List" /> + <ListToolbar + filters={[ + { source: 'q', label: 'Search', alwaysOn: true }, + { source: 'published', label: 'Published', type: 'boolean' }, + ]} + /> + <DataTable> + {data?.map(record => ( + <DataTable.Row key={record.id}> + <DataTable.Col source="title" record={record} /> + <DataTable.Col source="author" record={record} /> + <DataTable.Col source="published_at" record={record} /> + </DataTable.Row> + ))} + </DataTable> + <Pagination total={total} /> + </Card> + )} /> ); ``` -This custom List component has no aside component - it's up to you to add it in pure React. - ## Props -The `<ListBase>` component accepts the same props as [`useListController`](./useListController.md): +The `<ListBase>` component accepts the following props: +* `children` * [`debounce`](./List.md#debounce) * [`disableAuthentication`](./List.md#disableauthentication) * [`disableSyncWithLocation`](./List.md#disablesyncwithlocation) @@ -63,13 +89,13 @@ The `<ListBase>` component accepts the same props as [`useListController`](./use * [`filterDefaultValues`](./List.md#filterdefaultvalues) * [`perPage`](./List.md#perpage) * [`queryOptions`](./List.md#queryoptions) +* `render` * [`resource`](./List.md#resource) * [`sort`](./List.md#sort) -These are a subset of the props accepted by `<List>` - only the props that change data fetching, and not the props related to the user interface. - In addition, `<ListBase>` renders its children components inside a `ListContext`. Check [the `<List children>` documentation](./List.md#children) for usage examples. + ## Security The `<ListBase>` component requires authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](./List.md#disableauthentication) prop. diff --git a/docs/ReferenceArrayFieldBase.md b/docs/ReferenceArrayFieldBase.md new file mode 100644 index 00000000000..92c3a02f7f0 --- /dev/null +++ b/docs/ReferenceArrayFieldBase.md @@ -0,0 +1,226 @@ +--- +layout: default +title: "The ReferenceArrayFieldBase Component" +storybook_path: ra-core-fields-referencearrayfieldbase--basic +--- + +# `<ReferenceArrayFieldBase>` + +Use `<ReferenceArrayFieldBase>` to display a list of related records, via a one-to-many relationship materialized by an array of foreign keys. + +`<ReferenceArrayFieldBase>` fetches a list of referenced records (using the `dataProvider.getMany()` method), and puts them in a [`ListContext`](./useListContext.md). This component is headless, and its children need to use the data from this context to render the desired ui. + +**Tip**: For a rendering a list of chips by default, use [the `<ReferenceArrayField>` component](./ReferenceArrayField.md) instead. + +**Tip**: If the relationship is materialized by a foreign key on the referenced resource, use [the `<ReferenceManyFieldBase>` component](./ReferenceManyFieldBase.md) instead. + +## Usage + +For instance, let's consider a model where a `post` has many `tags`, materialized to a `tags_ids` field containing an array of ids: + +``` +┌──────────────┐ ┌────────┐ +│ posts │ │ tags │ +│--------------│ │--------│ +│ id │ ┌───│ id │ +│ title │ │ │ name │ +│ body │ │ └────────┘ +│ is_published │ │ +│ tag_ids │╾──┘ +└──────────────┘ +``` + +A typical `post` record therefore looks like this: + +```json +{ + "id": 1, + "title": "Hello world", + "body": "...", + "is_published": true, + "tags_ids": [1, 2, 3] +} +``` + +In that case, use `<ReferenceArrayFieldBase>` to display the post tag names as a list of chips, as follows: + +```jsx +import { ListBase, ListIterator, ReferenceArrayFieldBase } from 'react-admin'; + +export const PostList = () => ( + <ListBase> + <ListIterator> + <ReferenceArrayFieldBase reference="tags" source="tag_ids"> + <TagList /> + </ReferenceArrayFieldBase> + </ListIterator> + </ListBase> +); + +const TagList = (props: { children: React.ReactNode }) => { + const context = useListContext(); + + if (context.isPending) { + return <p>Loading...</p>; + } + + if (context.error) { + return <p className="error">{context.error.toString()}</p>; + } + return ( + <p> + {listContext.data?.map((tag, index) => ( + <li key={index}>{tag.name}</li> + ))} + </p> + ); +}; +``` + +`<ReferenceArrayFieldBase>` expects a `reference` attribute, which specifies the resource to fetch for the related records. It also expects a `source` attribute, which defines the field containing the list of ids to look for in the referenced resource. + +`<ReferenceArrayFieldBase>` fetches the `tag` resources related to each `post` resource by matching `post.tag_ids` to `tag.id`. + +You can change how the list of related records is rendered by passing a custom child reading the `ListContext` (e.g. a [`<DataTable>`](./DataTable.md)) or a render function prop. See the [`children`](#children) and the [`render`](#render) sections for details. + +## Props + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | --------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `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 renders a list of records | +| `filter` | Optional | `Object` | - | Filters to use when fetching the related records (the filtering is done client-side) | +| `perPage` | Optional | `number` | 1000 | Maximum number of results to display | +| `queryOptions` | Optional | [`UseQuery Options`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` options for the `getMany` query | +| `sort` | Optional | `{ field, order }` | `{ field: 'id', order: 'DESC' }` | Sort order to use when displaying the related records (the sort is done client-side) | +| `sortBy` | Optional | `string | Function` | `source` | When used in a `List`, name of the field to use for sorting when the user clicks on the column header. | + +\* Either one of children or render is required. + +## `children` + +You can pass any React component as child, to render the list of related records based on the `ListContext`. + +```jsx +<ReferenceArrayFieldBase label="Tags" reference="tags" source="tag_ids"> + <TagList /> +</ReferenceArrayFieldBase> + +const TagList = (props: { children: React.ReactNode }) => { + const { isPending, error, data } = useListContext(); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p className="error">{error.toString()}</p>; + } + return ( + <p> + {data?.map((tag, index) => ( + <li key={index}>{tag.name}</li> + ))} + </p> + ); +}; +``` + +## `render` + +Alternatively to `children`, you can pass a `render` function prop to `<ReferenceArrayFieldBase>`. The `render` prop will receive the `ListContext` as its argument, allowing to inline the rendering logic. + +```jsx +<ReferenceArrayFieldBase + label="Tags" + reference="tags" + source="tag_ids" + render={({ isPending, error, data }) => { + if (isPending) { + return <p>Loading...</p>; + } + if (error) { + return <p className="error">{error.toString()}</p>; + } + return ( + <p> + {data.map((tag, index) => ( + <li key={index}>{tag.name}</li> + ))} + </p> + ); + }} +/> +``` + +**Tip**: When receiving a `render` prop, the `<ReferenceArrayFieldBase>` component will ignore the `children` property. + +## `filter` + +`<ReferenceArrayFieldBase>` 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). + +For instance, to render only tags that are 'published', you can use the following code: + +{% raw %} +```jsx +<ReferenceArrayFieldBase + label="Tags" + source="tag_ids" + reference="tags" + filter={{ is_published: true }} +/> +``` +{% endraw %} + +## `perPage` + +`<ReferenceArrayFieldBase>` fetches *all* the related fields, and puts them all in a `ListContext`. If a record has a large number of related records, it may be a good idea to limit the number of displayed records. The `perPage` prop allows to create a client-side pagination for the related records. + +For instance, to limit the display of related records to 10, you can use the following code: + +```jsx + <ReferenceArrayFieldBase label="Tags" source="tag_ids" reference="tags" perPage={10} /> +``` + +## `queryOptions` + +Use the `queryOptions` prop to pass options to [the `dataProvider.getMany()` query](./useGetOne.md#aggregating-getone-calls) that fetches the referenced record. + +For instance, to pass [a custom `meta`](./Actions.md#meta-parameter): + +{% raw %} +```jsx +<ReferenceArrayFieldBase queryOptions={{ meta: { foo: 'bar' } }} /> +``` +{% endraw %} + +## `reference` + +The resource to fetch for the relateds record. + +For instance, if the `posts` resource has a `tag_ids` field, set the `reference` to `tags` to fetch the tags related to each post. + +```jsx +<ReferenceArrayFieldBase label="Tags" source="tag_ids" reference="tags" /> +``` + +## `sort` + +By default, the related records are displayed in the order in which they appear in the `source`. For instance, if the current record is `{ id: 1234, title: 'Lorem Ipsum', tag_ids: [1, 23, 4] }`, a `<ReferenceArrayFieldBase>` on the `tag_ids` field will display tags in the order 1, 23, 4. + +`<ReferenceArrayFieldBase>` can force a different order (via a client-side sort after fetch) if you specify a `sort` prop. + +For instance, to sort tags by title in ascending order, you can use the following code: + +{% raw %} +```jsx +<ReferenceArrayFieldBase + label="Tags" + source="tag_ids" + reference="tags" + sort={{ field: 'title', order: 'ASC' }} +/> +``` +{% endraw %} diff --git a/docs/ReferenceFieldBase.md b/docs/ReferenceFieldBase.md new file mode 100644 index 00000000000..d2c7695092e --- /dev/null +++ b/docs/ReferenceFieldBase.md @@ -0,0 +1,288 @@ +--- +layout: default +title: "The ReferenceFieldBase Component" +storybook_path: ra-core-controller-field-referencefieldbase--basic +--- + +# `<ReferenceFieldBase>` + +`<ReferenceFieldBase>` is useful for displaying many-to-one and one-to-one relationships, e.g. the details of a user when rendering a post authored by that user. +`<ReferenceFieldBase>` is a headless component, handling only the logic. This allows to use any UI library for the render. For a version based on MUI see [`<ReferenceField>`](/ReferenceField.html) + +## Usage + +For instance, let's consider a model where a `post` has one author from the `users` resource, referenced by a `user_id` field. + +``` +┌──────────────┐ ┌────────────────┐ +│ posts │ │ users │ +│--------------│ │----------------│ +│ id │ ┌───│ id │ +│ user_id │╾──┘ │ name │ +│ title │ │ date_of_birth │ +│ published_at │ └────────────────┘ +└──────────────┘ +``` + +In that case, use `<ReferenceFieldBase>` to display the post's author as follows: + +```jsx +import { Show, SimpleShowLayout, ReferenceField, TextField, RecordRepresentation } from 'react-admin'; + +export const PostShow = () => ( + <Show> + <SimpleShowLayout> + <TextField source="title" /> + <ReferenceFieldBase source="user_id" reference="users" > + <UserView /> + </ReferenceFieldBase> + </SimpleShowLayout> + </Show> +); + +export const UserView = () => { + const context = useReferenceFieldContext(); + + if (context.isPending) { + return <p>Loading...</p>; + } + + if (context.error) { + return <p className="error">{context.error.toString()}</p>; + } + + return <RecordRepresentation />; +}; +``` + +`<ReferenceFieldBase>` fetches the data, puts it in a [`RecordContext`](./useRecordContext.md), and its up to its children to handle the rendering by accessing the `ReferencingContext` using the `useReferenceFieldContext` hook. + +It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for performance reasons](#performance). When using several `<ReferenceFieldBase>` in the same page (e.g. in a `<DataTable>`), this allows to call the `dataProvider` once instead of once per row. + +## Props + +| Prop | Required | Type | Default | Description | +| ----------- | -------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | +| `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` | - | React component to render the referenced record. | +| `render` | Optional | `(context) => ReactNode` | - | Function that takes the referenceFieldContext and renders the referenced record. | +| `empty` | Optional | `ReactNode` | - | What to render when the field has no value or when the reference is missing | +| `queryOptions` | Optional | [`UseQuery Options`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | +| `sortBy` | Optional | `string | Function` | `source` | Name of the field to use for sorting when used in a Datagrid | + +## `children` + +You can pass any component of your own as child, to render the related records as you wish. +You can access the list context using the `useReferenceFieldContext` hook. + +```tsx +import { ReferenceFieldBase } from 'react-admin'; + +export const UserView = () => { + const { error, isPending, referenceRecord } = useReferenceFieldContext(); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p className="error">{error.toString()}</p>; + } + + return <>{referenceRecord.name}</>; +}; + +export const MyReferenceField = () => ( + <ReferenceFieldBase source="user_id" reference="users"> + <UserView /> + </ReferenceFieldBase> +); +``` + +## `render` + +Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ReferenceFieldContext` as argument. + +```jsx +export const MyReferenceField = () => ( + <ReferenceFieldBase + source="user_id" + reference="users" + render={({ error, isPending, referenceRecord }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p className="error"> + {error.message} + </p> + ); + } + return <p>{referenceRecord.name}</p>; + }} + /> +); +``` + +The `render` function prop will take priority on `children` props if both are set. + +## `empty` + +`<ReferenceFieldBase>` can display a custom message when the referenced record is missing, thanks to the `empty` prop. + +```jsx +<ReferenceFieldBase source="user_id" reference="users" empty="Missing user" > + ... +</ReferenceFieldBase> +``` + +`<ReferenceFieldBase>` renders the `empty` element when: + +- the referenced record is missing (no record in the `users` table with the right `user_id`), or +- the field is empty (no `user_id` in the record). + +You can pass either a React element or a string to the `empty` prop: + +```jsx +<ReferenceFieldBase source="user_id" reference="users" empty={<span>Missing user</span>} > + ... +</ReferenceFieldBase> +<ReferenceFieldBase source="user_id" reference="users" empty="Missing user" > + ... +</ReferenceFieldBase> +``` + +## `queryOptions` + +Use the `queryOptions` prop to pass options to [the `dataProvider.getMany()` query](./useGetOne.md#aggregating-getone-calls) that fetches the referenced record. + +For instance, to pass [a custom `meta`](./Actions.md#meta-parameter): + +{% raw %} +```jsx +<ReferenceFieldBase + source="user_id" + reference="users" + queryOptions={{ meta: { foo: 'bar' } }} + render={({ referenceRecord }) => referenceRecord.name} +> + ... +</ReferenceFieldBase> +``` +{% endraw %} + +## `reference` + +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 +<ReferenceFieldBase source="user_id" reference="users" > + ... +</ReferenceFieldBase> +``` + +## `sortBy` + +By default, when used in a `<Datagrid>`, and when the user clicks on the column header of a `<ReferenceFieldBase>`, react-admin sorts the list by the field `source`. To specify another field name to sort by, set the `sortBy` prop. + +```jsx +<ReferenceFieldBase source="user_id" reference="users" sortBy="user.name"> + ... +</ReferenceFieldBase> +``` + +## Performance + +<iframe src="https://www.youtube-nocookie.com/embed/egBhWqF3sWc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="aspect-ratio: 16 / 9;width:100%;margin-bottom:1em;"></iframe> + +When used in a `<DataTable>`, `<ReferenceFieldBase>` fetches the referenced record only once for the entire table. + +For instance, with this code: + +```jsx +import { ListBase, ListIterator, ReferenceFieldBase } from 'react-admin'; + +export const PostList = () => ( + <ListBase> + <ListIterator> + <ReferenceFieldBase source="user_id" reference="users"> + <AuthorView /> + </ReferenceFieldBase> + </ListIterator> + </ListBase> +); +``` + +React-admin accumulates and deduplicates the ids of the referenced records to make *one* `dataProvider.getMany()` call for the entire list, instead of n `dataProvider.getOne()` calls. So for instance, if the API returns the following list of posts: + +```js +[ + { + id: 123, + title: 'Totally agree', + user_id: 789, + }, + { + id: 124, + title: 'You are right my friend', + user_id: 789 + }, + { + id: 125, + title: 'Not sure about this one', + user_id: 735 + } +] +``` + +Then react-admin renders the `<PostList>` with a loader for the `<ReferenceFieldBase>`, fetches the API for the related users in one call (`dataProvider.getMany('users', { ids: [789,735] }`), and re-renders the list once the data arrives. This accelerates the rendering and minimizes network load. + +## Prefetching + +When you know that a page will contain a `<ReferenceFieldBase>`, you can configure the main page query to prefetch the referenced records to avoid a flicker when the data arrives. To do so, pass a `meta.prefetch` parameter to the page query. + +For example, the following code prefetches the authors referenced by the posts: + +{% raw %} +```jsx +const PostList = () => ( + <ListBase queryOptions={{ meta: { prefetch: ['author'] } }}> + <ListIterator + render={({ title, author_id }) => ( + <div> + <h3>{title}</h3> + <ReferenceFieldBase source="author_id" reference="authors"> + <AuthorView /> + </ReferenceFieldBase> + </div> + )} + /> + </ListBase> +); +``` +{% endraw %} + +**Note**: For prefetching to function correctly, your data provider must support [Prefetching Relationships](./DataProviders.md#prefetching-relationships). Refer to your data provider's documentation to verify if this feature is supported. + +**Note**: Prefetching is a frontend performance feature, designed to avoid flickers and repaints. It doesn't always prevent `<ReferenceFieldBase>` to fetch the data. For instance, when coming to a show view from a list view, the main record is already in the cache, so the page renders immediately, and both the page controller and the `<ReferenceFieldBase>` controller fetch the data in parallel. The prefetched data from the page controller arrives after the first render of the `<ReferenceFieldBase>`, so the data provider fetches the related data anyway. But from a user perspective, the page displays immediately, including the `<ReferenceFieldBase>`. If you want to avoid the `<ReferenceFieldBase>` to fetch the data, you can use the React Query Client's `staleTime` option. + +## Access Control + +If your authProvider implements [the `canAccess` method](./AuthProviderWriting.md#canaccess), React-Admin will verify whether users have access to the Show and Edit views. + +For instance, given the following `ReferenceFieldBase`: + +```jsx +<ReferenceFieldBase source="user_id" reference="users" /> +``` + +React-Admin will call `canAccess` with the following parameters: +- If the `users` resource has a Show view: `{ action: "show", resource: 'posts', record: Object }` +- If the `users` resource has an Edit view: `{ action: "edit", resource: 'posts', record: Object }` + +And the link property of the referenceField context will be set accordingly. It will be set to false if the access is denied. \ No newline at end of file diff --git a/docs/ReferenceManyFieldBase.md b/docs/ReferenceManyFieldBase.md new file mode 100644 index 00000000000..646bf008f98 --- /dev/null +++ b/docs/ReferenceManyFieldBase.md @@ -0,0 +1,360 @@ +--- +layout: default +title: "The ReferenceManyFieldBase Component" +storybook_path: ra-core-controller-field-referencemanyfieldbase--basic +--- + +# `<ReferenceManyFieldBase>` + +`<ReferenceManyFieldBase>` is useful for displaying a list of related records via a one-to-many relationship, when the foreign key is carried by the referenced resource. + +This component fetches a list of referenced records by a reverse lookup of the current `record.id` in the `target` field of another resource (using the `dataProvider.getManyReference()` REST method), and puts them in a [`ListContext`](./useListContext.md). + +This component is headless. It relies on its `children` or a `render` prop to render the desired ui. + +**Tip**: If the relationship is materialized by an array of ids in the initial record, use [the `<ReferenceArrayFieldBase>` component](./ReferenceArrayFieldBase.md) instead. + +## Usage + +For instance, if an `author` has many `books`, and each book resource exposes an `author_id` field: + +``` +┌────────────────┐ ┌──────────────┐ +│ authors │ │ books │ +│----------------│ │--------------│ +│ id │───┐ │ id │ +│ first_name │ └──╼│ author_id │ +│ last_name │ │ title │ +│ date_of_birth │ │ published_at │ +└────────────────┘ └──────────────┘ +``` + +`<ReferenceManyFieldBase>` can render the titles of all the books by a given author. + +```jsx +import { ShowBase, ReferenceManyFieldBase } from 'react-admin'; + +const AuthorShow = () => ( + <ShowBase> + <ReferenceManyFieldBase reference="books" target="author_id" > + <BookList source="title" /> + </ReferenceManyFieldBase> + </ShowBase> +); + +const BookList = ({ + source, + children, +}: { + source: string; +}) => { + const context = useListContext(); + + if (context.isPending) { + return <p>Loading...</p>; + } + + if (context.error) { + return <p className="error">{context.error.toString()}</p>; + } + return ( + <p> + {listContext.data?.map((book, index) => ( + <li key={index}>{book[source]}</li> + ))} + </p> + ); +}; +``` + +`<ReferenceManyFieldBase>` accepts a `reference` attribute, which specifies the resource to fetch for the related record. It also accepts a `source` attribute which defines the field containing the value to look for in the `target` field of the referenced resource. By default, this is the `id` of the resource (`authors.id` in the previous example). + +You can also use `<ReferenceManyFieldBase>` in a list, e.g. to display the authors of the comments related to each post in a list by matching `post.id` to `comment.post_id`: + +```jsx +import { ListBase, ListIterator, ReferenceManyFieldBase } from 'react-admin'; + +export const PostList = () => ( + <ListBase> + <ListIterator> + <ReferenceManyFieldBase reference="comments" target="post_id"> + <CustomAuthorView source="name"/> + </ReferenceManyFieldBase> + </ListIterator> + </ListBase> +); +``` + +## Props + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | --------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------- | +| `children` | Optional | `Element` | - | One or several elements that render a list of records based on a `ListContext` | +| `render` | Optional\* | `(ListContext) => Element` | - | Function that receives a `ListContext` and returns an element | +| `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()` | +| `perPage` | Optional | `number` | 25 | Maximum number of referenced records to fetch | +| `queryOptions` | Optional | [`UseQuery Options`](https://tanstack.com/query/v3/docs/react/reference/useQuery) | `{}` | `react-query` options for the `getMany` query | +| `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'books' | +| `sort` | Optional | `{ field, order }` | `{ field: 'id', order: 'DESC' }` | Sort order to use when fetching the related records, passed to `getManyReference()` | +| `source` | Optional | `string` | `id` | Target field carrying the relationship on the source record (usually 'id') | +| `storeKey` | Optional | `string` | - | The key to use to store the records selection state | +| `target` | Required | `string` | - | Target field carrying the relationship on the referenced resource, e.g. 'user_id' | + +\* Either one of children or render is required. + +## `children` + +`<ReferenceManyFieldBase>` renders its children inside a [`ListContext`](./useListContext.md). This means you can use any component that uses a `ListContext`: + +- [`<SingleFieldList>`](./SingleFieldList.md) +- [`<DataTable>`](./DataTable.md) +- [`<Datagrid>`](./Datagrid.md) +- [`<SimpleList>`](./SimpleList.md) +- [`<EditableDatagrid>`](./EditableDatagrid.md) +- [`<Calendar>`](./Calendar.md) + +For instance, use a `<ListIterator>` to render the related records: + +```jsx +import { ShowBase, ReferenceManyFieldBase, ListIterator } from 'react-admin'; + +export const AuthorShow = () => ( + <ShowBase> + <ReferenceManyFieldBase label="Books" reference="books" target="author_id"> + <ul> + <ListIterator render={(book) => ( + <li key={book.id}> + <i>{book.title}</i>, published on{' '}{book.published_at} + </li> + )}/> + </ul> + </ReferenceManyFieldBase> + </ShowBase> +); +``` + +## `render` + +Alternatively, you can pass a `render` function prop instead of children. The `render` prop will receive the `ListContext` as arguments, allowing to inline the render logic. +When receiving a `render` function prop the `<ReferenceManyFieldBase>` component will ignore the children property. + +```jsx +import { ShowBase, ReferenceManyFieldBase } from 'react-admin'; + +const AuthorShow = () => ( + <ShowBase> + <ReferenceManyFieldBase + reference="books" + target="author_id" + render={ + ({ isPending, error, data }) => { + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p className="error">{error.toString()}</p>; + } + return ( + <ul> + {data.map((book, index) => ( + <li key={index}> + <i>{book.title}</i>, published on{' '}{book.published_at} + </li> + ))} + </ul> + ); + } + } + /> + </ShowBase> +); +``` + +## `debounce` + +By default, `<ReferenceManyFieldBase>` 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. + +You can customize the debounce duration in milliseconds - or disable it completely - by passing a `debounce` prop to the `<ReferenceManyFieldBase>` component: + +```jsx +// wait 1 seconds instead of 500 milliseconds before calling the dataProvider +const PostCommentsField = () => ( + <ReferenceManyFieldBase debounce={1000}> + ... + </ReferenceManyFieldBase> +); +``` + +## `empty` + +Use `empty` to customize the text displayed when the related record is empty. + +```jsx +<ReferenceManyFieldBase + reference="books" + target="author_id" + empty="no books" +> + ... +</ReferenceManyFieldBase> +``` + +`empty` also accepts a translation key. + +```jsx +<ReferenceManyFieldBase + reference="books" + target="author_id" + empty="resources.authors.fields.books.empty" +> + ... +</ReferenceManyFieldBase> +``` + +`empty` also accepts a `ReactNode`. + +```jsx +<ReferenceManyFieldBase + reference="books" + target="author_id" + empty={<button onClick={...}>Create</button>} +> + ... +</ReferenceManyFieldBase> +``` + +## `filter`: Permanent Filter + +You can filter the query used to populate the possible values. Use the `filter` prop for that. + +{% raw %} + +```jsx +<ReferenceManyFieldBase + reference="comments" + target="post_id" + filter={{ is_published: true }} +> + ... +</ReferenceManyFieldBase> +``` + +{% endraw %} + +## `perPage` + +By default, react-admin restricts the possible values to 25 and displays no pagination control. You can change the limit by setting the `perPage` prop: + +```jsx +<ReferenceManyFieldBase perPage={10} reference="comments" target="post_id"> + ... +</ReferenceManyFieldBase> +``` + +## `queryOptions` + +Use the `queryOptions` prop to pass options to [the `dataProvider.getMany()` query](./useGetOne.md#aggregating-getone-calls) that fetches the referenced record. + +For instance, to pass [a custom `meta`](./Actions.md#meta-parameter): + +{% raw %} +```jsx +<ReferenceManyFieldBase queryOptions={{ meta: { foo: 'bar' } }}> + ... +</ReferenceManyFieldBase> +``` +{% endraw %} + +## `reference` + +The name of the resource to fetch for the related records. + +For instance, if you want to display the `books` of a given `author`, the `reference` name should be `books`: + +```jsx +<ReferenceManyFieldBase label="Books" reference="books" target="author_id"> + ... +</ReferenceManyFieldBase> +``` + +## `sort` + +By default, it 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 +<ReferenceManyFieldBase + target="post_id" + reference="comments" + sort={{ field: 'created_at', order: 'DESC' }} +> + ... +</ReferenceManyFieldBase> +``` +{% endraw %} + +## `source` + +By default, `ReferenceManyFieldBase` uses the `id` field as target for the reference. If the foreign key points to another field of your record, you can select it with the `source` prop. + +```jsx +<ReferenceManyFieldBase + target="post_id" + reference="comments" + source="_id" +> + ... +</ReferenceManyFieldBase> +``` + +## `storeKey` + +By default, react-admin stores the reference list selection state in localStorage so that users can come back to the list and find it in the same state as when they left it. React-admin uses the main resource, record id and reference resource as the identifier to store the selection state (under the key `${resource}.${record.id}.${reference}.selectedIds`). + +If you want to display multiple lists of the same reference and keep distinct selection states for each one, you must give each list a unique `storeKey` property. + +In the example below, both lists use the same reference ('books'), but their selection states are stored separately (under the store keys `'authors.1.books.selectedIds'` and `'custom.selectedIds'` respectively). This allows to use both components in the same page, each having its own state. + +{% raw %} +```jsx +<div> + <ReferenceManyFieldBase + reference="books" + target="author_id" + queryOptions={{ + meta: { foo: 'bar' }, + }} + > + <ListIterator render={(book) => ( + <p>{book.title}</p> + )} /> + </ReferenceManyFieldBase> + <ReferenceManyFieldBase + reference="books" + target="author_id" + queryOptions={{ + meta: { foo: 'bar' }, + }} + storeKey="custom" + > + <Iterator render={(book) => ( + <p>{book.title}</p> + )} /> + </ReferenceManyFieldBase> +</div> +``` +{% endraw %} + +## `target` + +Name of the field carrying the relationship on the referenced resource. For instance, if an `author` has many `books`, and each book resource exposes an `author_id` field, the `target` would be `author_id`. + +```jsx +<ReferenceManyFieldBase label="Books" reference="books" target="author_id"> + ... +</ReferenceManyFieldBase> +``` diff --git a/docs/ReferenceOneFieldBase.md b/docs/ReferenceOneFieldBase.md new file mode 100644 index 00000000000..0f119d04502 --- /dev/null +++ b/docs/ReferenceOneFieldBase.md @@ -0,0 +1,305 @@ +--- +layout: default +title: "The ReferenceOneFieldBase Component" +storybook_path: ra-ui-materialui-fields-referenceonefieldbase--basic +--- + +# `<ReferenceOneFieldBase>` + +This field fetches a one-to-one relationship, e.g. the details of a book, when using a foreign key on the distant resource. + +``` +┌──────────────┐ ┌──────────────┐ +│ books │ │ book_details │ +│--------------│ │--------------│ +│ id │───┐ │ id │ +│ title │ └──╼│ book_id │ +│ published_at │ │ genre │ +└──────────────┘ │ ISBN │ + └──────────────┘ +``` + +`<ReferenceOneFieldBase>` behaves like `<ReferenceManyFieldBase>`: 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. + +`<ReferenceOneFieldBase>` is a headless component, handling only the logic and relying on its `children` or `render` prop to render the UI. + +**Tip**: For a version based on MUI, see [`<ReferenceOneField>`](/ReferenceOneField.html) + +**Tip**: For the inverse relationships (the book linked to a book_detail), you can use a [`<ReferenceFieldBase>`](./ReferenceFieldBase.md). + +## Usage + +Here is how to render a field of the `book_details` resource inside a Show view for the `books` resource: + +```jsx +const BookShow = () => ( + <ShowBase> + <ReferenceOneFieldBase reference="book_details" target="book_id"> + <BookDetails /> + </ReferenceOneFieldBase> + </ShowBase> +); + +const BookDetails = () => { + const context = useReferenceFieldContext({ + reference, + target, + }); + + if (context.isPending) { + return <p>Loading...</p>; + } + + if (context.error) { + return <p className="error" >{context.error.toString()}</p>; + } + if (!context.referenceRecord) { + return <p>No details found</p>; + } + return ( + <div> + <p>{context.referenceRecord.genre}</p> + <p>{context.referenceRecord.ISBN}</p> + </div> + ); +} +``` + +**Tip**: As with `<ReferenceFieldBase>`, you can call `<ReferenceOneFieldBase>` as many times as you need in the same component, react-admin will only make one call to `dataProvider.getManyReference()` per reference. + +## Props + +| Prop | Required | Type | Default | Description | +| -------------- | -------- | ------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------- | +| `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` | - | React component to render the referenced record. | +| `render` | Optional\* | `(ReferenceFieldContext) => Element` | - | A function that takes the `ReferenceFieldContext` and return 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. | +| `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | +| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'ASC' }` | Used to order referenced records | + +`<ReferenceOneFieldBase>` also accepts the [common field props](./Fields.md#common-field-props). + +\* Either one of children or render is required. + +## `children` + +You can pass any component of your own as children, to render the referenced record as you wish. +You can access the list context using the `useReferenceFieldContext` hook. + +```jsx +const BookShow = () => ( + <ReferenceOneFieldBase reference="book_details" target="book_id"> + <BookDetails /> + </ReferenceOneFieldBase> +); + +const BookDetails = () => { + const { isPending, error, referenceRecord } = useReferenceFieldContext({ + reference, + target, + }); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p className="error" >{error.toString()}</p>; + } + if (!referenceRecord) { + return <p>No details found</p>; + } + return ( + <div> + <p>{referenceRecord.genre}</p> + <p>{referenceRecord.ISBN}</p> + </div> + ); +} +``` + +## `render` + +Alternatively to children you can pass a `render` function prop to `<ReferenceOneFieldBase>`. The `render` function prop will receive the `ReferenceFieldContext` as its argument, allowing to inline the render logic. +When receiving a `render` function prop the `<ReferenceOneFieldBase>` component will ignore the children property. + +```jsx +const BookShow = () => ( + <ReferenceOneFieldBase + reference="book_details" + target="book_id" + render={({ isPending, error, referenceRecord }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p className="error" >{error.toString()}</p>; + } + + if (!referenceRecord) { + return <p>No details found</p>; + } + return ( + <div> + <p>{referenceRecord.genre}</p> + <p>{referenceRecord.ISBN}</p> + </div> + ); + }} + /> +); +``` + +## `empty` + +Use `empty` to customize the text displayed when the related record is empty. + +```jsx +<ReferenceOneFieldBase label="Details" reference="book_details" target="book_id" empty="no detail"> + ... +</ReferenceOneFieldBase> +``` + +`empty` also accepts a translation key. + +```jsx +<ReferenceOneFieldBase label="Details" reference="book_details" target="book_id" empty="resources.books.not_found"> + ... +</ReferenceOneFieldBase> +``` + +`empty` also accepts a `ReactNode`. + +```jsx +<ReferenceOneFieldBase + label="Details" + reference="book_details" + target="book_id" + empty={<CreateButton to="/book_details/create" />} +> + ... +</ReferenceOneFieldBase> +``` + +## `filter` + +You can also use `<ReferenceOneFieldBase>` in a one-to-many relationship. In that case, the first record will be displayed. The `filter` prop becomes super useful in that case, as it allows you to select the appropriate record to display. + +For instance, if a product has prices in many currencies, and you only want to render the price in euros, you can use: + +{% raw %} +```jsx +<ReferenceOneFieldBase + reference="product_prices" + target="product_id" + filter={{ currency: "EUR" }} +> + ... +</ReferenceOneFieldBase> +``` +{% endraw %} + +## `link` + +By default, `<ReferenceOneFieldBase>` populates the context with a `link` value that links to the edition page of the related record. You can disable this behavior by setting the `link` prop to `false`. + +```jsx +<ReferenceOneFieldBase label="Genre" reference="book_details" target="book_id" link={false}> + ... +</ReferenceOneFieldBase> +``` + +You can also set the `link` prop to a string, which will be used as the link type. It can be either `edit`, `show`, a route path, or a function returning a route path based on the given record. + +{% raw %} +```jsx +<ReferenceOneFieldBase + reference="book_details" + target="book_id" + link={record => `/custom/${record.id}`} +> + ... +</ReferenceOneFieldBase> +``` +{% endraw %} + +## `queryOptions` + +`<ReferenceOneFieldBase>` uses `react-query` to fetch the related record. You can set [any of `useQuery` options](https://tanstack.com/query/v5/docs/react/reference/useQuery) via the `queryOptions` prop. + +For instance, if you want to disable the refetch on window focus for this query, you can use: + +{% raw %} +```jsx +<ReferenceOneFieldBase + label="Genre" + reference="book_details" + target="book_id" + queryOptions={{ refetchOnWindowFocus: false }} +> + ... +</ReferenceOneFieldBase> +``` +{% endraw %} + +## `reference` + +The name of the resource to fetch for the related records. + +For instance, if you want to display the details of a given book, the `reference` name should be `book_details`: + +```jsx +<ReferenceOneFieldBase label="Genre" reference="book_details" target="book_id"> + ... +</ReferenceOneFieldBase> +``` + +## `sort` + +You can also use `<ReferenceOneFieldBase>` in a one-to-many relationship. In that case, the first record will be displayed. This is where the `sort` prop comes in handy. It allows you to select the appropriate record to display. + +![ReferenceOneFieldBase for one-to-many relationships](./img/reference-one-field-many.png) + +For instance, if you want to display the latest message in a discussion, you can use: + +{% raw %} +```jsx +<ReferenceOneFieldBase + reference="messages" + target="discussion_id" + sort={{ field: "createdAt", order: "DESC" }} +> + ... +</ReferenceOneFieldBase> +``` +{% endraw %} + +## `target` + +The name of the field carrying the relationship on the referenced resource. + +For example, in the following schema, the relationship is carried by the `book_id` field: + +``` +┌──────────────┐ ┌──────────────┐ +│ books │ │ book_details │ +│--------------│ │--------------│ +│ id │───┐ │ id │ +│ title │ └──╼│ book_id │ +│ published_at │ │ genre │ +└──────────────┘ │ ISBN │ + └──────────────┘ +``` + +In that case, the `target` prop should be set to `book_id`: + +```jsx +<ReferenceOneFieldBase label="Genre" reference="book_details" target="book_id"> + ... +</ReferenceOneFieldBase> +``` diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 61ecdbc58f3..7e68a762962 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -62,7 +62,8 @@ const App = () => ( | Prop | Required | Type | Default | Description |------------------|----------|-------------------|---------|-------------------------------------------------------- -| `children` | Required | `ReactNode` | | The components rendering the record fields +| `children` | Optional | `ReactNode` | | The components rendering the record fields +| `render` | Optional | `(props: ShowControllerResult<RecordType>) => ReactNode` | | Alternative to children, a function that takes the ShowController context and renders the form | `disable Authentication` | Optional | `boolean` | | Set to `true` to disable the authentication check | `empty WhileLoading` | Optional | `boolean` | | Set to `true` to return `null` while the list is loading | `id` | Optional | `string` | | The record identifier. If not provided, it will be deduced from the URL @@ -106,6 +107,33 @@ const BookShow = () => ( ``` {% endraw %} +## `render` + +Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ShowContext` as argument. + +{% raw %} +```jsx +import { ShowBase, TextField, DateField, ReferenceField, WithRecord } from 'react-admin'; + +const BookShow = () => ( + <ShowBase render={({ isPending, error, record }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p className="error"> + {error.message} + </p> + ); + } + return <p>{record.title}</p>; + }}/> +); +``` +{% endraw %} + ## `disableAuthentication` By default, the `<ShowBase>` component will automatically redirect the user to the login page if the user is not authenticated. If you want to disable this behavior and allow anonymous access to a show page, set the `disableAuthentication` prop to `true`. diff --git a/packages/ra-core/src/controller/create/CreateBase.spec.tsx b/packages/ra-core/src/controller/create/CreateBase.spec.tsx index 8c47b1c99be..09ac36cbaab 100644 --- a/packages/ra-core/src/controller/create/CreateBase.spec.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.spec.tsx @@ -8,6 +8,7 @@ import { DefaultTitle, NoAuthProvider, WithAuthProviderNoAccessControl, + WithRenderProp, } from './CreateBase.stories'; describe('CreateBase', () => { @@ -283,4 +284,22 @@ describe('CreateBase', () => { fireEvent.click(screen.getByText('FR')); await screen.findByText('Créer un article (fr)'); }); + + it('should allow render props', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + create: jest.fn((_, { data }) => + Promise.resolve({ data: { id: 1, ...data } }) + ), + }); + + render(<WithRenderProp dataProvider={dataProvider} />); + fireEvent.click(screen.getByText('save')); + + await waitFor(() => { + expect(dataProvider.create).toHaveBeenCalledWith('posts', { + data: { test: 'test' }, + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/create/CreateBase.stories.tsx b/packages/ra-core/src/controller/create/CreateBase.stories.tsx index bf44009544e..d7dd302b6a1 100644 --- a/packages/ra-core/src/controller/create/CreateBase.stories.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.stories.tsx @@ -148,6 +148,28 @@ export const AccessControl = ({ </CoreAdminContext> ); +export const WithRenderProp = ({ + dataProvider = defaultDataProvider, + callTimeOptions, +}: { + dataProvider?: DataProvider; + callTimeOptions?: SaveHandlerCallbacks; +} & Partial<CreateBaseProps>) => ( + <CoreAdminContext dataProvider={dataProvider}> + <CreateBase + {...defaultProps} + render={({ save }) => { + const handleClick = () => { + if (!save) return; + save({ test: 'test' }, callTimeOptions); + }; + + return <button onClick={handleClick}>save</button>; + }} + /> + </CoreAdminContext> +); + const defaultDataProvider = testDataProvider({ // @ts-ignore create: (_, { data }) => Promise.resolve({ data: { id: 1, ...data } }), diff --git a/packages/ra-core/src/controller/create/CreateBase.tsx b/packages/ra-core/src/controller/create/CreateBase.tsx index 69d6f9569ed..f9e8b1d06d0 100644 --- a/packages/ra-core/src/controller/create/CreateBase.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.tsx @@ -3,6 +3,7 @@ import { ReactNode } from 'react'; import { useCreateController, CreateControllerProps, + CreateControllerResult, } from './useCreateController'; import { CreateContextProvider } from './CreateContextProvider'; import { Identifier, RaRecord } from '../../types'; @@ -44,6 +45,7 @@ export const CreateBase = < MutationOptionsError = Error, >({ children, + render, loading = null, ...props }: CreateBaseProps<RecordType, ResultRecordType, MutationOptionsError>) => { @@ -62,11 +64,17 @@ export const CreateBase = < return loading; } + if (!render && !children) { + throw new Error( + '<CreateBase> requires either a `render` prop or `children` prop' + ); + } + return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided <OptionalResourceContextProvider value={props.resource}> <CreateContextProvider value={controllerProps}> - {children} + {render ? render(controllerProps) : children} </CreateContextProvider> </OptionalResourceContextProvider> ); @@ -81,6 +89,7 @@ export interface CreateBaseProps< MutationOptionsError, ResultRecordType > { - children: ReactNode; + children?: ReactNode; + render?: (props: CreateControllerResult<RecordType>) => ReactNode; loading?: ReactNode; } diff --git a/packages/ra-core/src/controller/edit/EditBase.spec.tsx b/packages/ra-core/src/controller/edit/EditBase.spec.tsx index ae241a8af1e..b9cede99671 100644 --- a/packages/ra-core/src/controller/edit/EditBase.spec.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.spec.tsx @@ -8,13 +8,14 @@ import { DefaultTitle, NoAuthProvider, WithAuthProviderNoAccessControl, + WithRenderProps, } from './EditBase.stories'; describe('EditBase', () => { it('should give access to the save function', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) @@ -44,8 +45,8 @@ describe('EditBase', () => { it('should allow to override the onSuccess function', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) @@ -84,8 +85,8 @@ describe('EditBase', () => { it('should allow to override the onSuccess function at call time', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) @@ -128,8 +129,8 @@ describe('EditBase', () => { it('should allow to override the onError function', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), // @ts-ignore update: jest.fn(() => Promise.reject({ message: 'test' })), @@ -162,8 +163,8 @@ describe('EditBase', () => { it('should allow to override the onError function at call time', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), // @ts-ignore update: jest.fn(() => Promise.reject({ message: 'test' })), @@ -199,8 +200,8 @@ describe('EditBase', () => { it('should allow to override the transform function', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) @@ -239,8 +240,8 @@ describe('EditBase', () => { it('should allow to override the transform function at call time', async () => { const dataProvider = testDataProvider({ - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) @@ -376,4 +377,32 @@ describe('EditBase', () => { fireEvent.click(screen.getByText('FR')); await screen.findByText("Modifier l'article Hello (fr)"); }); + + it('should allow renderProp', async () => { + const dataProvider = testDataProvider({ + getOne: () => + // @ts-ignore + Promise.resolve({ data: { id: 12, test: 'Hello' } }), + update: jest.fn((_, { id, data, previousData }) => + Promise.resolve({ data: { id, ...previousData, ...data } }) + ), + }); + render( + <WithRenderProps + dataProvider={dataProvider} + mutationMode="pessimistic" + /> + ); + await screen.findByText('12'); + await screen.findByText('Hello'); + fireEvent.click(screen.getByText('save')); + + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('posts', { + id: 12, + data: { test: 'test' }, + previousData: { id: 12, test: 'Hello' }, + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/edit/EditBase.stories.tsx b/packages/ra-core/src/controller/edit/EditBase.stories.tsx index 37599384e09..10414473984 100644 --- a/packages/ra-core/src/controller/edit/EditBase.stories.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.stories.tsx @@ -16,6 +16,7 @@ import { mergeTranslations, useEditContext, useLocaleState, + MutationMode, } from '../..'; export default { @@ -149,6 +150,35 @@ export const AccessControl = ({ </CoreAdminContext> ); +export const WithRenderProps = ({ + dataProvider = defaultDataProvider, + mutationMode = 'optimistic', +}: { + dataProvider?: DataProvider; + mutationMode?: MutationMode; +}) => ( + <CoreAdminContext dataProvider={dataProvider}> + <EditBase + mutationMode={mutationMode} + {...defaultProps} + render={({ record, save }) => { + const handleClick = () => { + if (!save) return; + + save({ test: 'test' }); + }; + return ( + <> + <p>{record?.id}</p> + <p>{record?.test}</p> + <button onClick={handleClick}>save</button> + </> + ); + }} + /> + </CoreAdminContext> +); + const defaultDataProvider = testDataProvider({ getOne: () => // @ts-ignore diff --git a/packages/ra-core/src/controller/edit/EditBase.tsx b/packages/ra-core/src/controller/edit/EditBase.tsx index 97cd99d717a..c1015e7e6cc 100644 --- a/packages/ra-core/src/controller/edit/EditBase.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import { ReactNode } from 'react'; import { RaRecord } from '../../types'; -import { useEditController, EditControllerProps } from './useEditController'; +import { + useEditController, + EditControllerProps, + EditControllerResult, +} from './useEditController'; import { EditContextProvider } from './EditContextProvider'; import { OptionalResourceContextProvider } from '../../core'; import { useIsAuthPending } from '../../auth'; @@ -38,6 +42,7 @@ import { useIsAuthPending } from '../../auth'; */ export const EditBase = <RecordType extends RaRecord = any, ErrorType = Error>({ children, + render, loading = null, ...props }: EditBaseProps<RecordType, ErrorType>) => { @@ -52,11 +57,17 @@ export const EditBase = <RecordType extends RaRecord = any, ErrorType = Error>({ return loading; } + if (!render && !children) { + throw new Error( + "<EditBase> requires either a 'render' prop or 'children' prop" + ); + } + return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided <OptionalResourceContextProvider value={props.resource}> <EditContextProvider value={controllerProps}> - {children} + {render ? render(controllerProps) : children} </EditContextProvider> </OptionalResourceContextProvider> ); @@ -66,6 +77,7 @@ export interface EditBaseProps< RecordType extends RaRecord = RaRecord, ErrorType = Error, > extends EditControllerProps<RecordType, ErrorType> { - children: ReactNode; + children?: ReactNode; + render?: (props: EditControllerResult<RecordType, ErrorType>) => ReactNode; loading?: ReactNode; } diff --git a/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.spec.tsx new file mode 100644 index 00000000000..1b1ac1f65dc --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.spec.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { + Basic, + Errored, + Loading, + WithRenderProp, +} from './ReferenceArrayFieldBase.stories'; + +import { ReferenceArrayFieldBase } from './ReferenceArrayFieldBase'; +import { useResourceContext } from '../../core/useResourceContext'; +import { testDataProvider } from '../../dataProvider/testDataProvider'; +import { CoreAdminContext } from '../../core/CoreAdminContext'; + +describe('ReferenceArrayFieldBase', () => { + it('should display an error if error is defined', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + render(<Errored />); + await waitFor(() => { + expect(screen.queryByText('Error: Error')).not.toBeNull(); + }); + }); + + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + render(<Loading />); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + it('should pass the correct resource down to child component', async () => { + const MyComponent = () => { + const resource = useResourceContext(); + return <div>{resource}</div>; + }; + const dataProvider = testDataProvider({ + getList: () => + // @ts-ignore + Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), + }); + render( + <CoreAdminContext dataProvider={dataProvider}> + <ReferenceArrayFieldBase reference="posts" source="post_id"> + <MyComponent /> + </ReferenceArrayFieldBase> + </CoreAdminContext> + ); + await waitFor(() => { + expect(screen.queryByText('posts')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<Basic />); + 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(); + }); + }); + + it('should support renderProp', async () => { + render(<WithRenderProp />); + 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(); + }); + }); +}); diff --git a/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.stories.tsx new file mode 100644 index 00000000000..9e8685ffeca --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.stories.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import fakeRestProvider from 'ra-data-fakerest'; + +import { ReferenceArrayFieldBase } from './ReferenceArrayFieldBase'; +import { + CoreAdmin, + DataProvider, + Resource, + ShowBase, + TestMemoryRouter, + useListContext, +} from '../..'; +import { QueryClient } from '@tanstack/react-query'; + +export default { title: 'ra-core/controller/field/ReferenceArrayFieldBase' }; + +const fakeData = { + bands: [{ id: 1, name: 'The Beatles', members: [1, 2, 3, 4, 5, 6, 7, 8] }], + artists: [ + { id: 1, name: 'John Lennon' }, + { id: 2, name: 'Paul McCartney' }, + { id: 3, name: 'Ringo Star' }, + { id: 4, name: 'George Harrison' }, + { id: 5, name: 'Mick Jagger' }, + { id: 6, name: 'Keith Richards' }, + { id: 7, name: 'Ronnie Wood' }, + { id: 8, name: 'Charlie Watts' }, + ], +}; +const defaultDataProvider = fakeRestProvider(fakeData, false); + +export const Basic = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: DataProvider; +}) => ( + <TestMemoryRouter initialEntries={['/bands/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="artists" /> + <Resource + name="bands" + show={ + <ShowBase resource="bands" id={1}> + <ReferenceArrayFieldBase + source="members" + reference="artists" + > + <ArtistList /> + </ReferenceArrayFieldBase> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +const ArtistList = () => { + const { isPending, error, data } = useListContext(); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p style={{ color: 'red' }}>{error.toString()}</p>; + } + return ( + <p> + {data.map((datum, index) => ( + <li key={index}>{datum.name}</li> + ))} + </p> + ); +}; + +const erroredDataProvider = { + ...defaultDataProvider, + getMany: _resource => Promise.reject(new Error('Error')), +} as any; + +export const Errored = () => <Basic dataProvider={erroredDataProvider} />; + +const foreverLoadingDataProvider = { + ...defaultDataProvider, + getMany: _resource => new Promise(() => {}), +} as any; + +export const Loading = () => ( + <Basic dataProvider={foreverLoadingDataProvider} /> +); + +export const WithRenderProp = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: DataProvider; +}) => ( + <TestMemoryRouter initialEntries={['/bands/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="artists" /> + <Resource + name="bands" + show={ + <ShowBase resource="bands" id={1}> + <ReferenceArrayFieldBase + source="members" + reference="artists" + render={({ data, isPending, error }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p style={{ color: 'red' }}> + {error.toString()} + </p> + ); + } + + return ( + <p> + {data?.map((datum, index) => ( + <li key={index}>{datum.name}</li> + ))} + </p> + ); + }} + /> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); diff --git a/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.tsx b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.tsx new file mode 100644 index 00000000000..ba0ee3f1e19 --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceArrayFieldBase.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { type ReactElement, type ReactNode } from 'react'; + +import type { UseQueryOptions } from '@tanstack/react-query'; +import { FilterPayload, RaRecord, SortPayload } from '../../types'; +import { useRecordContext } from '../record'; +import { useReferenceArrayFieldController } from './useReferenceArrayFieldController'; +import { ResourceContextProvider } from '../../core'; +import { ListContextProvider, ListControllerResult } from '../list'; +import { BaseFieldProps } from './types'; + +/** + * A container component that fetches records from another resource specified + * by an array of *ids* in current record. + * + * You must define the fields to be passed to the iterator component as children. + * + * @example Display all the products of the current order as datagrid + * // order = { + * // id: 123, + * // product_ids: [456, 457, 458], + * // } + * <ReferenceArrayFieldBase label="Products" reference="products" source="product_ids"> + * <Datagrid> + * <TextField source="id" /> + * <TextField source="description" /> + * <NumberField source="price" options={{ style: 'currency', currency: 'USD' }} /> + * <EditButton /> + * </Datagrid> + * </ReferenceArrayFieldBase> + * + * @example Display all the categories of the current product as a list of chips + * // product = { + * // id: 456, + * // category_ids: [11, 22, 33], + * // } + * <ReferenceArrayFieldBase label="Categories" reference="categories" source="category_ids"> + * <SingleFieldList> + * <ChipField source="name" /> + * </SingleFieldList> + * </ReferenceArrayFieldBase> + * + * By default, restricts the displayed values to 1000. You can extend this limit + * by setting the `perPage` prop. + * + * @example + * <ReferenceArrayFieldBase perPage={10} reference="categories" source="category_ids"> + * ... + * </ReferenceArrayFieldBase> + * + * By default, the field displays the results in the order in which they are referenced + * (i.e. in the order of the list of ids). You can change this order + * by setting the `sort` prop (an object with `field` and `order` properties). + * + * @example + * <ReferenceArrayFieldBase sort={{ field: 'name', order: 'ASC' }} reference="categories" source="category_ids"> + * ... + * </ReferenceArrayFieldBase> + * + * Also, you can filter the results to display only a subset of values. Use the + * `filter` prop for that. + * + * @example + * <ReferenceArrayFieldBase filter={{ is_published: true }} reference="categories" source="category_ids"> + * ... + * </ReferenceArrayFieldBase> + */ +export const ReferenceArrayFieldBase = < + RecordType extends RaRecord = RaRecord, + ReferenceRecordType extends RaRecord = RaRecord, +>( + props: ReferenceArrayFieldBaseProps<RecordType, ReferenceRecordType> +) => { + const { + children, + render, + error, + loading, + empty, + filter, + page = 1, + perPage, + reference, + resource, + sort, + source, + queryOptions, + } = props; + const record = useRecordContext(props); + const controllerProps = useReferenceArrayFieldController< + RecordType, + ReferenceRecordType + >({ + filter, + page, + perPage, + record, + reference, + resource, + sort, + source, + queryOptions, + }); + + if (!render && !children) { + throw new Error( + "<ReferenceArrayFieldBase> requires either a 'render' prop or 'children' prop" + ); + } + + if (controllerProps.isPending && loading) { + return ( + <ResourceContextProvider value={reference}> + {loading} + </ResourceContextProvider> + ); + } + if (controllerProps.error && error) { + return ( + <ResourceContextProvider value={reference}> + <ListContextProvider value={controllerProps}> + {error} + </ListContextProvider> + </ResourceContextProvider> + ); + } + if ( + // there is an empty page component + empty && + // there is no error + !controllerProps.error && + // the list is not loading data for the first time + !controllerProps.isPending && + // the API returned no data (using either normal or partial pagination) + (controllerProps.total === 0 || + (controllerProps.total == null && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + controllerProps.hasPreviousPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + controllerProps.hasNextPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + controllerProps.data.length === 0)) && + // the user didn't set any filters + !Object.keys(controllerProps.filterValues).length + ) { + return ( + <ResourceContextProvider value={reference}> + {empty} + </ResourceContextProvider> + ); + } + + return ( + <ResourceContextProvider value={reference}> + <ListContextProvider value={controllerProps}> + {render ? render(controllerProps) : children} + </ListContextProvider> + </ResourceContextProvider> + ); +}; + +export interface ReferenceArrayFieldBaseProps< + RecordType extends RaRecord = RaRecord, + ReferenceRecordType extends RaRecord = RaRecord, +> extends BaseFieldProps<RecordType> { + children?: ReactNode; + render?: (props: ListControllerResult<ReferenceRecordType>) => ReactElement; + error?: ReactNode; + loading?: ReactNode; + empty?: ReactNode; + filter?: FilterPayload; + page?: number; + perPage?: number; + reference: string; + sort?: SortPayload; + queryOptions?: Omit< + UseQueryOptions<ReferenceRecordType[], Error>, + 'queryFn' | 'queryKey' + >; +} diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx index 6bdb4d49541..9ee4ae816b5 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx @@ -5,7 +5,13 @@ import { CoreAdminContext } from '../../core/CoreAdminContext'; import { useResourceContext } from '../../core/useResourceContext'; import { testDataProvider } from '../../dataProvider'; import { ReferenceFieldBase } from './ReferenceFieldBase'; -import { Error, Loading, Meta } from './ReferenceFieldBase.stories'; +import { + Basic, + Errored, + Loading, + Meta, + WithRenderProp, +} from './ReferenceFieldBase.stories'; describe('<ReferenceFieldBase />', () => { beforeAll(() => { @@ -17,9 +23,9 @@ describe('<ReferenceFieldBase />', () => { .mockImplementationOnce(() => {}) .mockImplementationOnce(() => {}); - render(<Error />); + render(<Errored />); await waitFor(() => { - expect(screen.queryByText('Error')).not.toBeNull(); + expect(screen.queryByText('Error: Error')).not.toBeNull(); }); }); @@ -64,6 +70,7 @@ describe('<ReferenceFieldBase />', () => { ); const dataProvider = testDataProvider({ getMany, + // @ts-ignore getOne: () => Promise.resolve({ data: { @@ -86,4 +93,76 @@ describe('<ReferenceFieldBase />', () => { }); }); }); + + it('should render the data', async () => { + render(<Basic />); + await waitFor(() => { + expect(screen.queryByText('Leo')).not.toBeNull(); + }); + }); + + describe('with render prop', () => { + it('should display an error if error is defined', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + const dataProviderWithAuthorsError = { + getOne: () => + 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 => Promise.reject(new Error('Error')), + } as any; + + render( + <WithRenderProp dataProvider={dataProviderWithAuthorsError} /> + ); + await waitFor(() => { + expect(screen.queryByText('Error')).not.toBeNull(); + }); + }); + + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + const dataProviderWithAuthorsLoading = { + getOne: () => + 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 => new Promise(() => {}), + } as any; + + render( + <WithRenderProp dataProvider={dataProviderWithAuthorsLoading} /> + ); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<WithRenderProp />); + await waitFor(() => { + expect(screen.queryByText('Leo')).not.toBeNull(); + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx index 5ef4bdafd4d..168f35c0909 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx @@ -94,10 +94,10 @@ const dataProviderWithAuthorsError = { year: 1869, }, }), - getMany: _resource => Promise.reject('Error'), + getMany: _resource => Promise.reject(new Error('Error')), } as any; -export const Error = ({ dataProvider = dataProviderWithAuthorsError }) => ( +export const Errored = ({ dataProvider = dataProviderWithAuthorsError }) => ( <TestMemoryRouter initialEntries={['/books/1/show']}> <CoreAdmin dataProvider={dataProvider} @@ -351,6 +351,50 @@ export const Meta = ({ </TestMemoryRouter> ); +export const WithRenderProp = ({ dataProvider = dataProviderWithAuthors }) => ( + <TestMemoryRouter initialEntries={['/books/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="authors" /> + <Resource + name="books" + show={ + <ShowBase> + <ReferenceFieldBase + source="author" + reference="authors" + render={({ error, isPending }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p style={{ color: 'red' }}> + {error.message} + </p> + ); + } + return <TextField source="first_name" />; + }} + /> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + const MyReferenceField = (props: { children: React.ReactNode }) => { const context = useReferenceFieldContext(); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx index 7b4a6a065b8..8a43db5087c 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx @@ -3,7 +3,10 @@ import { ReactNode } from 'react'; import { UseQueryOptions } from '@tanstack/react-query'; import { ReferenceFieldContextProvider } from './ReferenceFieldContext'; import { RaRecord } from '../../types'; -import { useReferenceFieldController } from './useReferenceFieldController'; +import { + useReferenceFieldController, + UseReferenceFieldControllerResult, +} from './useReferenceFieldController'; import { ResourceContextProvider } from '../../core'; import { RecordContextProvider } from '../record'; import { useFieldValue } from '../../util'; @@ -15,25 +18,25 @@ import { useFieldValue } from '../../util'; * added as <Admin> child. * * @example // using recordRepresentation - * <ReferenceFieldBase label="User" source="userId" reference="users" /> + * <ReferenceFieldBase source="userId" reference="users" /> * * @example // using a Field component to represent the record - * <ReferenceFieldBase label="User" source="userId" reference="users"> + * <ReferenceFieldBase source="userId" reference="users"> * <TextField source="name" /> * </ReferenceFieldBase> * * @example // By default, includes a link to the <Edit> page of the related record * // (`/users/:userId` in the previous example). * // Set the `link` prop to "show" to link to the <Show> page instead. - * <ReferenceFieldBase label="User" source="userId" reference="users" link="show" /> + * <ReferenceFieldBase source="userId" reference="users" link="show" /> * * @example // You can also prevent `<ReferenceFieldBase>` from adding link to children * // by setting `link` to false. - * <ReferenceFieldBase label="User" source="userId" reference="users" link={false} /> + * <ReferenceFieldBase source="userId" reference="users" link={false} /> * * @example // Alternatively, you can also pass a custom function to `link`. * // It must take reference and record as arguments and return a string - * <ReferenceFieldBase label="User" source="userId" reference="users" link={(record, reference) => "/path/to/${reference}/${record}"} /> + * <ReferenceFieldBase source="userId" reference="users" link={(record, reference) => "/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 @@ -44,11 +47,34 @@ export const ReferenceFieldBase = < >( props: ReferenceFieldBaseProps<ReferenceRecordType> ) => { - const { children, empty = null } = props; + const { children, render, loading, error, empty = null } = props; const id = useFieldValue(props); + const controllerProps = useReferenceFieldController<ReferenceRecordType>(props); + if (!render && !children) { + throw new Error( + "<ReferenceFieldBase> requires either a 'render' prop or 'children' prop" + ); + } + + if (controllerProps.isPending && loading) { + return ( + <ResourceContextProvider value={props.reference}> + {loading} + </ResourceContextProvider> + ); + } + if (controllerProps.error && error) { + return ( + <ResourceContextProvider value={props.reference}> + <ReferenceFieldContextProvider value={controllerProps}> + {error} + </ReferenceFieldContextProvider> + </ResourceContextProvider> + ); + } if ( (empty && // no foreign key value @@ -58,13 +84,18 @@ export const ReferenceFieldBase = < !controllerProps.isPending && !controllerProps.referenceRecord) ) { - return empty; + return ( + <ResourceContextProvider value={props.reference}> + {empty} + </ResourceContextProvider> + ); } + return ( <ResourceContextProvider value={props.reference}> <ReferenceFieldContextProvider value={controllerProps}> <RecordContextProvider value={controllerProps.referenceRecord}> - {children} + {render ? render(controllerProps) : children} </RecordContextProvider> </ReferenceFieldContextProvider> </ResourceContextProvider> @@ -75,9 +106,13 @@ export interface ReferenceFieldBaseProps< ReferenceRecordType extends RaRecord = RaRecord, > { children?: ReactNode; + render?: ( + props: UseReferenceFieldControllerResult<ReferenceRecordType> + ) => ReactNode; className?: string; empty?: ReactNode; error?: ReactNode; + loading?: ReactNode; queryOptions?: Partial< UseQueryOptions<ReferenceRecordType[], Error> & { meta?: any; diff --git a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.spec.tsx new file mode 100644 index 00000000000..a7c76e18d96 --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.spec.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { + Basic, + Errored, + Loading, + WithRenderProp, +} from './ReferenceManyFieldBase.stories'; + +import { ReferenceManyFieldBase } from './ReferenceManyFieldBase'; +import { useResourceContext } from '../../core/useResourceContext'; +import { testDataProvider } from '../../dataProvider/testDataProvider'; +import { CoreAdminContext } from '../../core/CoreAdminContext'; + +describe('ReferenceManyFieldBase', () => { + it('should display an error if error is defined', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + render(<Errored />); + await waitFor(() => { + expect(screen.queryByText('Error: Error')).not.toBeNull(); + }); + }); + + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + render(<Loading />); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + it('should pass the correct resource down to child component', async () => { + const MyComponent = () => { + const resource = useResourceContext(); + return <div>{resource}</div>; + }; + const dataProvider = testDataProvider({ + getList: () => + // @ts-ignore + Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), + }); + render( + <CoreAdminContext dataProvider={dataProvider}> + <ReferenceManyFieldBase + reference="posts" + source="post_id" + target="post" + > + <MyComponent /> + </ReferenceManyFieldBase> + </CoreAdminContext> + ); + await waitFor(() => { + expect(screen.queryByText('posts')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<Basic />); + await waitFor(() => { + expect(screen.queryByText('War and Peace')).not.toBeNull(); + expect(screen.queryByText('Anna Karenina')).not.toBeNull(); + expect(screen.queryByText('The Kreutzer Sonata')).not.toBeNull(); + }); + }); + + describe('with render prop', () => { + it('should display an error if error is defined', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + const dataProviderWithAuthorsError = { + getOne: () => + 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 => Promise.reject(new Error('Error')), + getManyReference: () => Promise.reject(new Error('Error')), + } as any; + + render( + <WithRenderProp dataProvider={dataProviderWithAuthorsError} /> + ); + await waitFor(() => { + expect(screen.queryByText('Error')).not.toBeNull(); + }); + }); + + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + const dataProviderWithAuthorsLoading = { + getOne: () => + 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 => new Promise(() => {}), + } as any; + + render( + <WithRenderProp dataProvider={dataProviderWithAuthorsLoading} /> + ); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<WithRenderProp />); + await waitFor(() => { + expect(screen.queryByText('War and Peace')).not.toBeNull(); + expect(screen.queryByText('Anna Karenina')).not.toBeNull(); + expect( + screen.queryByText('The Kreutzer Sonata') + ).not.toBeNull(); + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx new file mode 100644 index 00000000000..eab2b8eef8d --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx @@ -0,0 +1,326 @@ +import * as React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { CoreAdmin } from '../../core/CoreAdmin'; +import { Resource } from '../../core/Resource'; +import { ShowBase } from '../../controller/show/ShowBase'; +import { TestMemoryRouter } from '../../routing'; +import { ReferenceManyFieldBase } from './ReferenceManyFieldBase'; +import { ListBase, ListIterator, useListContext } from '../list'; +import { DataTableBase } from '../../dataTable'; +import fakeRestDataProvider from 'ra-data-fakerest'; + +export default { + title: 'ra-core/controller/field/ReferenceManyFieldBase', + excludeStories: ['dataProviderWithAuthors'], +}; + +const author = { + id: 1, + first_name: 'Leo', + last_name: 'Tolstoy', + language: 'Russian', +}; + +const books = [ + { + id: 1, + title: 'War and Peace', + author: 1, + }, + { + id: 2, + title: 'Anna Karenina', + author: 1, + }, + { + id: 3, + title: 'The Kreutzer Sonata', + author: 1, + }, + { + id: 4, + author: 2, + title: 'Hamlet', + }, +]; + +export const dataProviderWithAuthors = { + getOne: async () => ({ data: author }), + getMany: async (_resource, params) => ({ + data: books.filter(book => params.ids.includes(book.author)), + }), + getManyReference: async (_resource, params) => { + const result = books.filter(book => book.author === params.id); + return { + data: result.slice( + (params.pagination.page - 1) * params.pagination.perPage, + (params.pagination.page - 1) * params.pagination.perPage + + params.pagination.perPage + ), + total: result.length, + }; + }, +} as any; + +export const Basic = ({ dataProvider = dataProviderWithAuthors }) => ( + <TestMemoryRouter initialEntries={['/authors/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="books" /> + <Resource + name="authors" + show={ + <ShowBase> + <ReferenceManyFieldBase + target="author" + source="id" + reference="books" + > + <AuthorList source="title" /> + </ReferenceManyFieldBase> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +const dataProviderWithAuthorList = fakeRestDataProvider( + { + authors: [ + { + id: 1, + first_name: 'Leo', + last_name: 'Tolstoy', + language: 'Russian', + }, + { + id: 2, + first_name: 'William', + last_name: 'Shakespear', + language: 'English', + }, + ], + books, + }, + process.env.NODE_ENV === 'development' +); + +export const InAList = ({ dataProvider = dataProviderWithAuthorList }) => ( + <TestMemoryRouter initialEntries={['/authors']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource + name="authors" + list={ + <ListBase> + <ListIterator + render={author => ( + <div> + <h3>{author.last_name} Books</h3> + <ReferenceManyFieldBase + target="author" + source="id" + reference="books" + > + <AuthorList source="title" /> + </ReferenceManyFieldBase> + </div> + )} + ></ListIterator> + </ListBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +const dataProviderWithAuthorsError = { + getOne: () => + 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: books.filter(book => params.ids.includes(book.author)), + }), + getManyReference: _resource => Promise.reject(new Error('Error')), +} as any; + +export const Errored = ({ dataProvider = dataProviderWithAuthorsError }) => ( + <TestMemoryRouter initialEntries={['/books/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="authors" /> + <Resource + name="books" + show={ + <ShowBase> + <ReferenceManyFieldBase + reference="authors" + target="id" + source="author" + > + <AuthorList source="first_name" /> + </ReferenceManyFieldBase> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +const dataProviderWithAuthorsLoading = { + getOne: () => + Promise.resolve({ + data: author, + }), + + getMany: (_resource, params) => + Promise.resolve({ + data: books.filter(book => params.ids.includes(book.author)), + }), + getManyReference: _resource => new Promise(() => {}), +} as any; + +export const Loading = ({ dataProvider = dataProviderWithAuthorsLoading }) => ( + <TestMemoryRouter initialEntries={['/books/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="authors" /> + <Resource + name="books" + show={ + <ShowBase> + <ReferenceManyFieldBase + reference="authors" + target="id" + source="author" + > + <AuthorList source="first_name" /> + </ReferenceManyFieldBase> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +export const WithRenderProp = ({ + dataProvider = dataProviderWithAuthors, +}: { + dataProvider?: any; +}) => ( + <TestMemoryRouter initialEntries={['/books/1/show']}> + <CoreAdmin + dataProvider={dataProvider} + queryClient={ + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + } + > + <Resource name="authors" /> + <Resource + name="books" + show={ + <ShowBase> + <ReferenceManyFieldBase + reference="books" + target="author" + source="id" + render={({ error, isPending, data }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p style={{ color: 'red' }}> + {error.message} + </p> + ); + } + return ( + <p> + {data?.map((datum, index) => ( + <li key={index}>{datum.title}</li> + ))} + </p> + ); + }} + /> + </ShowBase> + } + /> + </CoreAdmin> + </TestMemoryRouter> +); + +const AuthorList = ({ source }) => { + const { isPending, error, data } = useListContext(); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p style={{ color: 'red' }}>{error.toString()}</p>; + } + return ( + <p> + {data?.map((datum, index) => <li key={index}>{datum[source]}</li>)} + </p> + ); +}; diff --git a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.tsx b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.tsx index d969805f7f3..fe5b1b9e021 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.tsx @@ -6,6 +6,7 @@ import { type UseReferenceManyFieldControllerParams, } from './useReferenceManyFieldController'; import type { RaRecord } from '../../types'; +import { ListControllerResult } from '../list'; /** * Render related records to the current one. @@ -61,11 +62,13 @@ export const ReferenceManyFieldBase = < ) => { const { children, + render, debounce, empty, + error, + loading, filter = defaultFilter, page = 1, - pagination = null, perPage = 25, record, reference, @@ -95,6 +98,28 @@ export const ReferenceManyFieldBase = < queryOptions, }); + if (!render && !children) { + throw new Error( + "<ReferenceManyFieldBase> requires either a 'render' prop or 'children' prop" + ); + } + + if (controllerProps.isPending && loading) { + return ( + <ResourceContextProvider value={reference}> + {loading} + </ResourceContextProvider> + ); + } + if (controllerProps.error && error) { + return ( + <ResourceContextProvider value={reference}> + <ListContextProvider value={controllerProps}> + {error} + </ListContextProvider> + </ResourceContextProvider> + ); + } if ( // there is an empty page component empty && @@ -114,14 +139,17 @@ export const ReferenceManyFieldBase = < // the user didn't set any filters !Object.keys(controllerProps.filterValues).length ) { - return empty; + return ( + <ResourceContextProvider value={reference}> + {empty} + </ResourceContextProvider> + ); } return ( <ResourceContextProvider value={reference}> <ListContextProvider value={controllerProps}> - {children} - {pagination} + {render ? render(controllerProps) : children} </ListContextProvider> </ResourceContextProvider> ); @@ -129,14 +157,16 @@ export const ReferenceManyFieldBase = < export interface ReferenceManyFieldBaseProps< RecordType extends Record<string, any> = Record<string, any>, - ReferenceRecordType extends Record<string, any> = Record<string, any>, + ReferenceRecordType extends RaRecord = RaRecord, > extends UseReferenceManyFieldControllerParams< RecordType, ReferenceRecordType > { - children: ReactNode; + children?: ReactNode; + render?: (props: ListControllerResult<ReferenceRecordType>) => ReactNode; empty?: ReactNode; - pagination?: ReactNode; + error?: ReactNode; + loading?: ReactNode; } const defaultFilter = {}; diff --git a/packages/ra-core/src/controller/field/ReferenceOneFieldBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.spec.tsx new file mode 100644 index 00000000000..3a003c2439a --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.spec.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { + Basic, + Loading, + WithRenderProp, +} from './ReferenceOneFieldBase.stories'; + +describe('ReferenceOneFieldBase', () => { + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + render(<Loading />); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<Basic />); + await waitFor(() => { + expect(screen.queryByText('9780393966473')).not.toBeNull(); + }); + }); + + describe('with render prop', () => { + it('should pass the loading state', async () => { + jest.spyOn(console, 'error') + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => {}); + + const dataProviderWithAuthorsLoading = { + getOne: () => + 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 => new Promise(() => {}), + } as any; + + render( + <WithRenderProp dataProvider={dataProviderWithAuthorsLoading} /> + ); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeNull(); + }); + }); + + it('should render the data', async () => { + render(<WithRenderProp />); + await waitFor(() => { + expect(screen.queryByText('9780393966473')).not.toBeNull(); + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/field/ReferenceOneFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.stories.tsx new file mode 100644 index 00000000000..64dad507bac --- /dev/null +++ b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.stories.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { + CoreAdminContext, + RecordContextProvider, + ReferenceOneFieldBase, + ResourceContextProvider, + TestMemoryRouter, + useReferenceFieldContext, +} from '../..'; + +export default { title: 'ra-core/controller/field/ReferenceOneFieldBase' }; + +const defaultDataProvider = { + getManyReference: () => + Promise.resolve({ + data: [{ id: 1, ISBN: '9780393966473', genre: 'novel' }], + total: 1, + }), +} as any; + +const Wrapper = ({ children, dataProvider = defaultDataProvider }) => ( + <TestMemoryRouter initialEntries={['/books/1/show']}> + <CoreAdminContext dataProvider={dataProvider}> + <ResourceContextProvider value="books"> + <RecordContextProvider + value={{ id: 1, title: 'War and Peace' }} + > + {children} + </RecordContextProvider> + </ResourceContextProvider> + </CoreAdminContext> + </TestMemoryRouter> +); + +export const Basic = () => ( + <Wrapper> + <ReferenceOneFieldBase reference="book_details" target="book_id"> + <BookDetails /> + </ReferenceOneFieldBase> + </Wrapper> +); + +const BookDetails = () => { + const { isPending, error, referenceRecord } = useReferenceFieldContext(); + + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return <p style={{ color: 'red' }}>{error.toString()}</p>; + } + if (!referenceRecord) { + return <p>No details found</p>; + } + + return <span>{referenceRecord.ISBN}</span>; +}; + +const dataProviderWithLoading = { + getManyReference: () => new Promise(() => {}), +} as any; + +export const Loading = () => ( + <Wrapper dataProvider={dataProviderWithLoading}> + <ReferenceOneFieldBase reference="book_details" target="book_id"> + <BookDetails /> + </ReferenceOneFieldBase> + </Wrapper> +); + +export const WithRenderProp = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: any; +}) => { + return ( + <Wrapper dataProvider={dataProvider}> + <ReferenceOneFieldBase + reference="book_details" + target="book_id" + render={({ isPending, error, referenceRecord }) => { + if (isPending) { + return <p>Loading...</p>; + } + + if (error) { + return ( + <p style={{ color: 'red' }}>{error.toString()}</p> + ); + } + return ( + <span> + {referenceRecord ? referenceRecord.ISBN : ''} + </span> + ); + }} + /> + </Wrapper> + ); +}; diff --git a/packages/ra-core/src/controller/field/ReferenceOneFieldBase.tsx b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.tsx index c54f73aa5f1..ed056b20827 100644 --- a/packages/ra-core/src/controller/field/ReferenceOneFieldBase.tsx +++ b/packages/ra-core/src/controller/field/ReferenceOneFieldBase.tsx @@ -10,6 +10,7 @@ import { useGetPathForRecord } from '../../routing'; import type { UseReferenceFieldControllerResult } from './useReferenceFieldController'; import type { RaRecord } from '../../types'; import type { LinkToType } from '../../routing'; +import { UseReferenceResult } from '../useReference'; /** * Render the related record in a one-to-one relationship @@ -29,11 +30,14 @@ export const ReferenceOneFieldBase = < ) => { const { children, + render, record, reference, source = 'id', target, empty, + error, + loading, sort, filter, link, @@ -67,19 +71,45 @@ export const ReferenceOneFieldBase = < [controllerProps, path] ); + if (!render && !children) { + throw new Error( + "<ReferenceOneFieldBase> requires either a 'render' prop or 'children' prop" + ); + } + const recordFromContext = useRecordContext<RecordType>(props); + if (controllerProps.isPending && loading) { + return ( + <ResourceContextProvider value={reference}> + {loading} + </ResourceContextProvider> + ); + } + if (controllerProps.error && error) { + return ( + <ResourceContextProvider value={reference}> + <ReferenceFieldContextProvider value={context}> + {error} + </ReferenceFieldContextProvider> + </ResourceContextProvider> + ); + } if ( !recordFromContext || (!controllerProps.isPending && controllerProps.referenceRecord == null) ) { - return empty; + return ( + <ResourceContextProvider value={reference}> + {empty} + </ResourceContextProvider> + ); } return ( <ResourceContextProvider value={reference}> <ReferenceFieldContextProvider value={context}> <RecordContextProvider value={context.referenceRecord}> - {children} + {render ? render(controllerProps) : children} </RecordContextProvider> </ReferenceFieldContextProvider> </ResourceContextProvider> @@ -94,7 +124,10 @@ export interface ReferenceOneFieldBaseProps< ReferenceRecordType > { children?: ReactNode; - link?: LinkToType<ReferenceRecordType>; + loading?: ReactNode; + error?: ReactNode; empty?: ReactNode; + render?: (props: UseReferenceResult<ReferenceRecordType>) => ReactNode; + link?: LinkToType<ReferenceRecordType>; resource?: string; } diff --git a/packages/ra-core/src/controller/field/index.ts b/packages/ra-core/src/controller/field/index.ts index 873cce05bdb..c11005b7912 100644 --- a/packages/ra-core/src/controller/field/index.ts +++ b/packages/ra-core/src/controller/field/index.ts @@ -3,6 +3,8 @@ export * from './ReferenceFieldBase'; export * from './ReferenceFieldContext'; export * from './ReferenceManyCountBase'; export * from './ReferenceManyFieldBase'; +export * from './ReferenceArrayFieldBase'; +export * from './types'; export * from './useReferenceArrayFieldController'; export * from './useReferenceFieldController'; export * from './useReferenceManyFieldController'; diff --git a/packages/ra-core/src/controller/field/types.ts b/packages/ra-core/src/controller/field/types.ts new file mode 100644 index 00000000000..c18c4be5409 --- /dev/null +++ b/packages/ra-core/src/controller/field/types.ts @@ -0,0 +1,33 @@ +import { ExtractRecordPaths } from '../../types'; + +export interface BaseFieldProps< + RecordType extends Record<string, any> = Record<string, any>, +> { + /** + * Name of the property to display. + * + * @see https://marmelab.com/react-admin/Fields.html#source + * @example + * const CommentList = () => ( + * <List> + * <Datagrid> + * <TextField source="author.name" /> + * <TextField source="body" /> + * </Datagrid> + * </List> + * ); + */ + source: ExtractRecordPaths<RecordType>; + + /** + * The current record to use. Defaults to the `RecordContext` value. + * + * @see https://marmelab.com/react-admin/Fields.html#record + */ + record?: RecordType; + + /** + * The resource name. Defaults to the `ResourceContext` value. + */ + resource?: string; +} diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx index d288bc4c0da..c471272b73e 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx @@ -5,6 +5,7 @@ import { DefaultTitle, NoAuthProvider, WithAuthProviderNoAccessControl, + WithRenderProps, } from './InfiniteListBase.stories'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { testDataProvider } from '../../dataProvider'; @@ -127,4 +128,10 @@ describe('InfiniteListBase', () => { fireEvent.click(screen.getByText('FR')); await screen.findByText('Liste des livres (fr)'); }); + + it('should allow render props', async () => { + render(<WithRenderProps />); + await screen.findByText('War and Peace'); + expect(screen.queryByText('Loading...')).toBeNull(); + }); }); diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx index a647d0d826c..3c23bb6a8c8 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx @@ -246,6 +246,74 @@ DefaultTitle.argTypes = { }, }; +export const WithRenderProps = () => ( + <CoreAdminContext dataProvider={defaultDataProvider}> + <InfiniteListBase + resource="books" + perPage={5} + render={context => { + const { + hasNextPage, + fetchNextPage, + isFetchingNextPage, + hasPreviousPage, + fetchPreviousPage, + isFetchingPreviousPage, + setFilters, + isPending, + setSort, + sort, + filterValues, + data, + } = context; + + if (isPending) { + return <div>Loading...</div>; + } + const toggleSort = () => { + setSort({ + field: sort.field === 'title' ? 'id' : 'title', + order: 'ASC', + }); + }; + const toggleFilter = () => { + setFilters(filterValues.q ? {} : { q: 'The ' }); + }; + + return ( + <div> + <div> + {hasPreviousPage && ( + <button + onClick={() => fetchPreviousPage()} + disabled={isFetchingPreviousPage} + > + Previous + </button> + )} + {hasNextPage && ( + <button + onClick={() => fetchNextPage()} + disabled={isFetchingNextPage} + > + Next + </button> + )} + </div> + <button onClick={toggleSort}>Toggle Sort</button> + <button onClick={toggleFilter}>Toggle Filter</button> + <ul> + {data?.map((record: any) => ( + <li key={record.id}>{record.title}</li> + ))} + </ul> + </div> + ); + }} + /> + </CoreAdminContext> +); + const Title = () => { const { defaultTitle } = useListContext(); const [locale, setLocale] = useLocaleState(); diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx index 6decf7ae8ab..b9c7e511860 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx @@ -3,6 +3,7 @@ import { ReactNode } from 'react'; import { useInfiniteListController, InfiniteListControllerProps, + InfiniteListControllerResult, } from './useInfiniteListController'; import { OptionalResourceContextProvider } from '../../core'; import { RaRecord } from '../../types'; @@ -46,6 +47,7 @@ import { useIsAuthPending } from '../../auth'; */ export const InfiniteListBase = <RecordType extends RaRecord = any>({ children, + render, loading = null, ...props }: InfiniteListBaseProps<RecordType>) => { @@ -59,6 +61,12 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({ return loading; } + if (!render && !children) { + throw new Error( + "<InfiniteListBase> requires either a 'render' prop or 'children' prop" + ); + } + return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided <OptionalResourceContextProvider value={props.resource}> @@ -74,7 +82,7 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({ controllerProps.isFetchingPreviousPage, }} > - {children} + {render ? render(controllerProps) : children} </InfinitePaginationContext.Provider> </ListContextProvider> </OptionalResourceContextProvider> @@ -83,6 +91,7 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({ export interface InfiniteListBaseProps<RecordType extends RaRecord = any> extends InfiniteListControllerProps<RecordType> { - children: ReactNode; loading?: ReactNode; + children?: ReactNode; + render?: (props: InfiniteListControllerResult<RecordType>) => ReactNode; } diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx index df80b38d36e..a4785fcd659 100644 --- a/packages/ra-core/src/controller/list/ListBase.spec.tsx +++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx @@ -5,6 +5,7 @@ import { DefaultTitle, NoAuthProvider, WithAuthProviderNoAccessControl, + WithRenderProps, } from './ListBase.stories'; import { testDataProvider } from '../../dataProvider'; @@ -103,4 +104,16 @@ describe('ListBase', () => { fireEvent.click(screen.getByText('FR')); await screen.findByText('Liste des livres (fr)'); }); + + it('should allow to use render props', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render(<WithRenderProps dataProvider={dataProvider} />); + expect(dataProvider.getList).toHaveBeenCalled(); + await screen.findByText('Hello'); + }); }); diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx index 4adf9b56375..5ffe3f678e8 100644 --- a/packages/ra-core/src/controller/list/ListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx @@ -282,6 +282,61 @@ export const DefaultTitle = ({ </CoreAdminContext> ); +export const WithRenderProps = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: DataProvider; +}) => ( + <CoreAdminContext dataProvider={dataProvider}> + <ListBase + resource="books" + perPage={5} + render={controllerProps => { + const { + data, + error, + isPending, + page, + perPage, + setPage, + total, + } = controllerProps; + if (isPending) { + return <div>Loading...</div>; + } + if (error) { + return <div>Error...</div>; + } + + return ( + <div> + <button + disabled={page <= 1} + onClick={() => setPage(page - 1)} + > + previous + </button> + <span> + Page {page} of {Math.ceil(total / perPage)} + </span> + <button + disabled={page >= total / perPage} + onClick={() => setPage(page + 1)} + > + next + </button> + <ul> + {data.map((record: any) => ( + <li key={record.id}>{record.title}</li> + ))} + </ul> + </div> + ); + }} + ></ListBase> + </CoreAdminContext> +); + DefaultTitle.args = { translations: 'default', }; diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx index e5d14f4d1b4..684e15fdc6b 100644 --- a/packages/ra-core/src/controller/list/ListBase.tsx +++ b/packages/ra-core/src/controller/list/ListBase.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import { ReactNode } from 'react'; -import { useListController, ListControllerProps } from './useListController'; +import { + useListController, + ListControllerProps, + ListControllerResult, +} from './useListController'; import { OptionalResourceContextProvider } from '../../core'; import { RaRecord } from '../../types'; import { ListContextProvider } from './ListContextProvider'; @@ -42,6 +46,7 @@ import { useIsAuthPending } from '../../auth'; */ export const ListBase = <RecordType extends RaRecord = any>({ children, + render, loading = null, ...props }: ListBaseProps<RecordType>) => { @@ -54,12 +59,17 @@ export const ListBase = <RecordType extends RaRecord = any>({ if (isAuthPending && !props.disableAuthentication) { return loading; } + if (!render && !children) { + throw new Error( + "<ListBase> requires either a 'render' prop or 'children' prop" + ); + } return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided <OptionalResourceContextProvider value={props.resource}> <ListContextProvider value={controllerProps}> - {children} + {render ? render(controllerProps) : children} </ListContextProvider> </OptionalResourceContextProvider> ); @@ -67,6 +77,7 @@ export const ListBase = <RecordType extends RaRecord = any>({ export interface ListBaseProps<RecordType extends RaRecord = any> extends ListControllerProps<RecordType> { - children: ReactNode; + children?: ReactNode; + render?: (props: ListControllerResult<RecordType, Error>) => ReactNode; loading?: ReactNode; } diff --git a/packages/ra-core/src/controller/show/ShowBase.spec.tsx b/packages/ra-core/src/controller/show/ShowBase.spec.tsx index 71f9db96fb0..7429cf5cfc9 100644 --- a/packages/ra-core/src/controller/show/ShowBase.spec.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.spec.tsx @@ -8,6 +8,7 @@ import { DefaultTitle, NoAuthProvider, WithAuthProviderNoAccessControl, + WithRenderProp, } from './ShowBase.stories'; describe('ShowBase', () => { @@ -105,4 +106,16 @@ describe('ShowBase', () => { fireEvent.click(screen.getByText('FR')); await screen.findByText("Détails de l'article Hello (fr)"); }); + + it('should support render prop', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + getOne: jest.fn(() => + Promise.resolve({ data: { id: 12, test: 'Hello' } }) + ), + }); + render(<WithRenderProp dataProvider={dataProvider} />); + expect(dataProvider.getOne).toHaveBeenCalled(); + await screen.findByText('Hello'); + }); }); diff --git a/packages/ra-core/src/controller/show/ShowBase.stories.tsx b/packages/ra-core/src/controller/show/ShowBase.stories.tsx index 68533e85c81..adc42bc74e2 100644 --- a/packages/ra-core/src/controller/show/ShowBase.stories.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.stories.tsx @@ -145,6 +145,23 @@ export const AccessControl = ({ </CoreAdminContext> ); +export const WithRenderProp = ({ + dataProvider = defaultDataProvider, + ...props +}: { + dataProvider?: DataProvider; +} & Partial<ShowBaseProps>) => ( + <CoreAdminContext dataProvider={dataProvider}> + <ShowBase + {...defaultProps} + {...props} + render={({ record }) => { + return <p>{record?.test}</p>; + }} + /> + </CoreAdminContext> +); + const defaultDataProvider = testDataProvider({ getOne: () => // @ts-ignore diff --git a/packages/ra-core/src/controller/show/ShowBase.tsx b/packages/ra-core/src/controller/show/ShowBase.tsx index 7b221c1715a..264a07b7866 100644 --- a/packages/ra-core/src/controller/show/ShowBase.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.tsx @@ -1,7 +1,11 @@ import * as React from 'react'; import { RaRecord } from '../../types'; -import { useShowController, ShowControllerProps } from './useShowController'; +import { + useShowController, + ShowControllerProps, + ShowControllerResult, +} from './useShowController'; import { ShowContextProvider } from './ShowContextProvider'; import { OptionalResourceContextProvider } from '../../core'; import { useIsAuthPending } from '../../auth'; @@ -37,6 +41,7 @@ import { useIsAuthPending } from '../../auth'; */ export const ShowBase = <RecordType extends RaRecord = any>({ children, + render, loading = null, ...props }: ShowBaseProps<RecordType>) => { @@ -51,11 +56,17 @@ export const ShowBase = <RecordType extends RaRecord = any>({ return loading; } + if (!render && !children) { + throw new Error( + '<ShowBase> requires either a `render` prop or `children` prop' + ); + } + return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided <OptionalResourceContextProvider value={props.resource}> <ShowContextProvider value={controllerProps}> - {children} + {render ? render(controllerProps) : children} </ShowContextProvider> </OptionalResourceContextProvider> ); @@ -63,6 +74,7 @@ export const ShowBase = <RecordType extends RaRecord = any>({ export interface ShowBaseProps<RecordType extends RaRecord = RaRecord> extends ShowControllerProps<RecordType> { - children: React.ReactNode; + children?: React.ReactNode; + render?: (props: ShowControllerResult<RecordType>) => React.ReactNode; loading?: React.ReactNode; } diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx index 01e184df22f..bbf45fed5a3 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx @@ -1,15 +1,11 @@ import * as React from 'react'; -import { memo, type ReactElement, type ReactNode } from 'react'; +import { memo } from 'react'; import { - ListContextProvider, useListContext, type ListControllerProps, - useReferenceArrayFieldController, - type SortPayload, - type FilterPayload, - ResourceContextProvider, - useRecordContext, + ReferenceArrayFieldBase, type RaRecord, + ReferenceArrayFieldBaseProps, } from 'ra-core'; import { type ComponentsOverrides, @@ -18,7 +14,6 @@ import { type Theme, useThemeProps, } from '@mui/material/styles'; -import type { UseQueryOptions } from '@tanstack/react-query'; import type { FieldProps } from './types'; import { LinearProgress } from '../layout'; @@ -90,60 +85,34 @@ export const ReferenceArrayField = < props: inProps, name: PREFIX, }); - const { - filter, - page = 1, - perPage, - reference, - resource, - sort, - source, - queryOptions, - } = props; - const record = useRecordContext(props); - const controllerProps = useReferenceArrayFieldController< - RecordType, - ReferenceRecordType - >({ - filter, - page, - perPage, - record, - reference, - resource, - sort, - source, - queryOptions, - }); + const { pagination, children, className, sx, ...controllerProps } = props; return ( - <ResourceContextProvider value={reference}> - <ListContextProvider value={controllerProps}> - <PureReferenceArrayFieldView {...props} /> - </ListContextProvider> - </ResourceContextProvider> + <ReferenceArrayFieldBase {...controllerProps}> + <PureReferenceArrayFieldView + pagination={pagination} + className={className} + sx={sx} + > + {children} + </PureReferenceArrayFieldView> + </ReferenceArrayFieldBase> ); }; export interface ReferenceArrayFieldProps< RecordType extends RaRecord = RaRecord, ReferenceRecordType extends RaRecord = RaRecord, -> extends FieldProps<RecordType> { - children?: ReactNode; - filter?: FilterPayload; - page?: number; - pagination?: ReactElement; - perPage?: number; - reference: string; - sort?: SortPayload; +> extends ReferenceArrayFieldBaseProps<RecordType, ReferenceRecordType>, + FieldProps<RecordType> { sx?: SxProps<Theme>; - queryOptions?: Omit< - UseQueryOptions<ReferenceRecordType[], Error>, - 'queryFn' | 'queryKey' - >; + pagination?: React.ReactElement; } -export interface ReferenceArrayFieldViewProps - extends Omit<ReferenceArrayFieldProps, 'resource' | 'page' | 'perPage'>, - Omit<ListControllerProps, 'queryOptions'> {} +export interface ReferenceArrayFieldViewProps { + pagination?: React.ReactElement; + children?: React.ReactNode; + className?: string; + sx?: SxProps<Theme>; +} export const ReferenceArrayFieldView = ( props: ReferenceArrayFieldViewProps diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyField.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyField.tsx index c044075c2ac..52cca798f45 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyField.tsx @@ -62,24 +62,30 @@ export const ReferenceManyField = < props: ReferenceManyFieldProps<RecordType, ReferenceRecordType> ) => { const translate = useTranslate(); + const { children, pagination, empty, ...controllerProps } = props; return ( <ReferenceManyFieldBase<RecordType, ReferenceRecordType> - {...props} + {...controllerProps} empty={ - typeof props.empty === 'string' ? ( + typeof empty === 'string' ? ( <Typography component="span" variant="body2"> - {translate(props.empty, { _: props.empty })} + {translate(empty, { _: empty })} </Typography> ) : ( - props.empty + empty ) } - /> + > + {children} + {pagination} + </ReferenceManyFieldBase> ); }; export interface ReferenceManyFieldProps< RecordType extends Record<string, any> = Record<string, any>, - ReferenceRecordType extends Record<string, any> = Record<string, any>, + ReferenceRecordType extends RaRecord = RaRecord, > extends Omit<FieldProps<RecordType>, 'source'>, - ReferenceManyFieldBaseProps<RecordType, ReferenceRecordType> {} + ReferenceManyFieldBaseProps<RecordType, ReferenceRecordType> { + pagination?: React.ReactElement; +} diff --git a/packages/ra-ui-materialui/src/field/types.ts b/packages/ra-ui-materialui/src/field/types.ts index 70db854a0b2..092f862a05a 100644 --- a/packages/ra-ui-materialui/src/field/types.ts +++ b/packages/ra-ui-materialui/src/field/types.ts @@ -1,112 +1,112 @@ import { ReactElement } from 'react'; import { TableCellProps } from '@mui/material/TableCell'; -import { ExtractRecordPaths, HintedString } from 'ra-core'; +import { BaseFieldProps, ExtractRecordPaths, HintedString } from 'ra-core'; type TextAlign = TableCellProps['align']; type SortOrder = 'ASC' | 'DESC'; export interface FieldProps< RecordType extends Record<string, any> = Record<string, any>, -> { +> extends BaseFieldProps<RecordType> { /** - * The field to use for sorting when users click this column head, if sortable. + * A class name to apply to the root div element + */ + className?: string; + + /** + * A class name to apply to the cell element when used inside <Datagrid>. + */ + cellClassName?: string; + + /** + * A class name to apply to the header cell element when used inside <Datagrid>. + */ + headerClassName?: string; + + /** + * Label to use as column header when using <Datagrid> or <SimpleShowLayout>. + * Defaults to the capitalized field name. Set to false to disable the label. * - * @see https://marmelab.com/react-admin/Fields.html#sortby + * @see https://marmelab.com/react-admin/Fields.html#label * @example * const PostList = () => ( * <List> * <Datagrid> * <TextField source="title" /> - * <ReferenceField source="author_id" sortBy="author.name"> - * <TextField source="name" /> - * </ReferenceField> + * <TextField source="body" label="Content" /> * </Datagrid> * </List> * ); */ - sortBy?: HintedString<ExtractRecordPaths<RecordType>>; + label?: string | ReactElement | boolean; /** - * The order used for sorting when users click this column head, if sortable. + * Set it to false to disable the click handler on the column header when used inside <Datagrid>. * - * @see https://marmelab.com/react-admin/Fields.html#sortbyorder + * @see https://marmelab.com/react-admin/Fields.html#sortable * @example * const PostList = () => ( * <List> * <Datagrid> * <TextField source="title" /> - * <DateField source="updated_at" sortByOrder="DESC" /> + * <ReferenceField source="author_id" sortable={false}> + * <TextField source="name" /> + * </ReferenceField> * </Datagrid> * </List> * ); */ - sortByOrder?: SortOrder; + sortable?: boolean; /** - * Name of the property to display. + * The text to display when the field value is empty. Defaults to empty string. * - * @see https://marmelab.com/react-admin/Fields.html#source + * @see https://marmelab.com/react-admin/Fields.html#emptytext * @example - * const CommentList = () => ( + * const PostList = () => ( * <List> * <Datagrid> - * <TextField source="author.name" /> - * <TextField source="body" /> + * <TextField source="title" /> + * <TextField source="author" emptyText="missing data" /> * </Datagrid> * </List> * ); */ - source: ExtractRecordPaths<RecordType>; + emptyText?: string; /** - * Label to use as column header when using <Datagrid> or <SimpleShowLayout>. - * Defaults to the capitalized field name. Set to false to disable the label. + * The field to use for sorting when users click this column head, if sortable. * - * @see https://marmelab.com/react-admin/Fields.html#label + * @see https://marmelab.com/react-admin/Fields.html#sortby * @example * const PostList = () => ( * <List> * <Datagrid> * <TextField source="title" /> - * <TextField source="body" label="Content" /> + * <ReferenceField source="author_id" sortBy="author.name"> + * <TextField source="name" /> + * </ReferenceField> * </Datagrid> * </List> * ); */ - label?: string | ReactElement | boolean; + sortBy?: HintedString<ExtractRecordPaths<RecordType>>; /** - * Set it to false to disable the click handler on the column header when used inside <Datagrid>. + * The order used for sorting when users click this column head, if sortable. * - * @see https://marmelab.com/react-admin/Fields.html#sortable + * @see https://marmelab.com/react-admin/Fields.html#sortbyorder * @example * const PostList = () => ( * <List> * <Datagrid> * <TextField source="title" /> - * <ReferenceField source="author_id" sortable={false}> - * <TextField source="name" /> - * </ReferenceField> + * <DateField source="updated_at" sortByOrder="DESC" /> * </Datagrid> * </List> * ); */ - sortable?: boolean; - - /** - * A class name to apply to the root div element - */ - className?: string; - - /** - * A class name to apply to the cell element when used inside <Datagrid>. - */ - cellClassName?: string; - - /** - * A class name to apply to the header cell element when used inside <Datagrid>. - */ - headerClassName?: string; + sortByOrder?: SortOrder; /** * The text alignment for the cell content, when used inside <Datagrid>. @@ -127,36 +127,8 @@ export interface FieldProps< */ textAlign?: TextAlign; - /** - * The text to display when the field value is empty. Defaults to empty string. - * - * @see https://marmelab.com/react-admin/Fields.html#emptytext - * @example - * const PostList = () => ( - * <List> - * <Datagrid> - * <TextField source="title" /> - * <TextField source="author" emptyText="missing data" /> - * </Datagrid> - * </List> - * ); - */ - emptyText?: string; - /** * @deprecated */ fullWidth?: boolean; - - /** - * The current record to use. Defaults to the `RecordContext` value. - * - * @see https://marmelab.com/react-admin/Fields.html#record - */ - record?: RecordType; - - /** - * The resource name. Defaults to the `ResourceContext` value. - */ - resource?: string; } diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx index e27ea5d683b..20beac31d58 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 = <Loading />; export interface InfiniteListProps<RecordType extends RaRecord = any> - extends InfiniteListBaseProps<RecordType>, + extends Omit<InfiniteListBaseProps<RecordType>, 'children'>, ListViewProps {} const PREFIX = 'RaInfiniteList'; diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 503e64272da..23b980e1c81 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -99,7 +99,7 @@ export const List = <RecordType extends RaRecord = any>( }; export interface ListProps<RecordType extends RaRecord = any> - extends ListBaseProps<RecordType>, + extends Omit<ListBaseProps<RecordType>, 'children'>, ListViewProps {} const defaultFilter = {};