diff --git a/docs/ListIterator.md b/docs/ListIterator.md deleted file mode 100644 index e03d91018cf..00000000000 --- a/docs/ListIterator.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -layout: default -title: "ListIterator" -storybook_path: ra-core-controller-list-listiterator--using-render ---- - -# `` - -## Usage - -Use the `` component to render a list of records in a custom way. Pass a `render` function to customize how each record is displayed. - -{% raw %} -```jsx -import { ListBase, ListIterator } from 'react-admin'; - -const MostVisitedPosts = () => ( - -
    -
  • {record.title} - {record.views}
  • } - /> -
-
-); -``` -{% endraw %} - -You can use `` as a child of any component that provides a [`ListContext`](./useListContext.md), such as: - -- [``](./List.md), -- [``](./ListGuesser.md), -- [``](./ListBase.md), -- [``](./ReferenceArrayField.md), -- [``](./ReferenceManyField.md) - -**Tip**: React-admin provides several list components that use `` internally, that you should prefer if you want to render a list of records in a standard way: - -- [``](./DataTable.md) renders a list of records in a table format. -- [``](./SimpleList.md) renders a list of records in a simple format, suitable for mobile devices. -- [``](./SingleFieldList.md) renders a list of records with a single field. - -## Props - -Here are all the props you can set on the `` component: - -| Prop | Required | Type | Default | Description | -| ----------- | -------- | ---------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | -| `children` | Optional | `ReactNode` | - | The content to render for each record | -| `data` | Optional | `RaRecord[]` | - | The records. Defaults to the `data` from the `ListContext` | -| `empty` | Optional | `ReactNode` | `null` | The content to display when there is no data | -| `error` | Optional | `ReactNode` | `null` | The content to display when the data fetching fails | -| `isPending` | Optional | `boolean` | - | A boolean indicating whether the data is pending. Defaults to the `isPending` from the `ListContext` | -| `loading` | Optional | `ReactNode` | `null` | The content to display while the data is loading | -| `render` | Optional | `(record: RaRecord) => ReactNode` | - | A function that returns the content to render for each record | -| `total` | Optional | `number` | - | The total number of records. Defaults to the `total` from the `ListContext` | - -Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform). - -## `children` - -If provided, `ListIterator` will render the `children` prop once for each record, inside a [`RecordContext`](./useRecordContext.md). - -{% raw %} -```tsx -import { ListIterator, useRecordContext } from 'react-admin'; - -const PostList = () => ( -
    - - - -
-); - -const PostItem = () => { - const record = useRecordContext(); - if (!record) return null; - return
  • {record.title} - {record.views}
  • ; -}; -``` -{% endraw %} - -**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. - -## `data` - -Although `` reads the data from the closest [``](./useListContext), you may provide it yourself when no such context is available: - -{% raw %} -```jsx -import { ListIterator } from 'react-admin'; -import { customerSegments } from './customerSegments.json'; - -const PostList = () => ( -
      -
    • {record.name}
    • } - /> -
    -); -``` -{% endraw %} - -## `empty` - -To provide a custom UI when there is no data, use the `empty` prop. - -{% raw %} -```jsx -import { ListBase, ListIterator } from 'react-admin'; - -const PostList = () => ( -
      - No posts found} - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` -{% endraw %} - -## `error` - -To provide a custom UI when the data fetching fails, use the `error` prop. - -{% raw %} -```jsx -import { ListIterator } from 'react-admin'; - -const PostList = () => ( -
      - Error loading posts} - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` -{% endraw %} - -## `isPending` - -Although `` reads the `isPending` from the closest [``](./useListContext), you may provide it yourself when no such context is available. This is useful when dealing with data not coming from the `dataProvider`: - -{% raw %} -```tsx -import { ListIterator } from 'react-admin'; -import { useQuery } from '@tanstack/react-query'; -import { fetchPostAnalytics } from './fetchPostAnalytics'; - -const DashboardMostVisitedPosts = () => { - const { data, isPending } = useQuery({ - queryKey: ['dashboard', 'posts'], - queryFn: fetchPostAnalytics - }); - - return ( -
      -
    • {record.title} - {record.views}
    • } - /> -
    - ); -} -``` -{% endraw %} - - -## `loading` - -To provide a custom UI while the data is loading, use the `loading` prop. - -{% raw %} -```jsx -import { ListIterator } from 'react-admin'; -import { Skeleton } from 'my-favorite-ui-lib'; - -const PostList = () => ( -
      - } - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` -{% endraw %} - -## `render` - -If provided, `ListIterator` will call the `render` prop for each record. This is useful when the components you render need the record data to render themselves, or when you want to pass additional props to the rendered component. - -{% raw %} -```tsx -import { ListBase, ListIterator } from 'react-admin'; - -const PostList = () => ( -
      -
    • {record.title} - {record.views}
    • } - /> -
    -); -``` -{% endraw %} - -**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. - -## `total` - -Although `` reads the total from the closest [``](./useListContext), you may provide it yourself when no such context is available: - -{% raw %} -```jsx -import { ListIterator } from 'react-admin'; -import { customerSegments } from './customerSegments.json'; - -const PostList = () => ( -
      -
    • {record.name}
    • } - /> -
    -); -``` -{% endraw %} - diff --git a/docs/RecordsIterator.md b/docs/RecordsIterator.md new file mode 100644 index 00000000000..869d3731e9d --- /dev/null +++ b/docs/RecordsIterator.md @@ -0,0 +1,211 @@ +--- +layout: default +title: "RecordsIterator" +storybook_path: ra-core-controller-list-recordsiterator +--- + +# `` + +Use the `` component to render a list of records in a custom way. Pass a `render` function to customize how each record is displayed. Pass a `data` prop to use it out of a list context. + +## Usage + +Use `` inside a [`ListContext`](./useListContext.md) to render each record: + +{% raw %} +```jsx +import { ListBase, RecordsIterator } from 'react-admin'; + +const MostVisitedPosts = () => ( + +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +); +``` +{% endraw %} + +You can use `` as a child of any component that provides a [`ListContext`](./useListContext.md), such as: + +- [``](./List.md), +- [``](./ListGuesser.md), +- [``](./ListBase.md), +- [``](./ReferenceArrayField.md), +- [``](./ReferenceManyField.md) + +`` expects that data is properly loaded, without error. If you want to handle loading, error, offline and empty states, use properties on the component providing you the list context (like [``](./List.md), [``](./ReferenceArrayField.md), [``](./ReferenceManyField.md)). You can also make use of [``](./WithListContext.md) [`loading`](./WithListContext.md#loading), [`errorElement`](./WithListContext.md#errorelement), [`offline`](./WithListContext.md#offline) and [`empty`](./WithListContext.md#empty) props. + +{% raw %} +```jsx +import { ListBase, RecordsIterator, WithListContext } from 'react-admin'; + +const MostVisitedPosts = () => ( + + Loading...

    } + errorElement={

    Something went wrong

    } + empty={

    No posts found

    } + offline={

    You are offline

    } + > +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +
    +); +``` +{% endraw %} + +## Props + +`` exposes the following important props: + +| Prop | Required | Type | Default | Description | +| ----------- | -------- |-----------------------------------| ------- | ---------------------------------------------------------------------------------------------------- | +| `children` | Optional | `ReactNode` | - | The content to render for each record | +| `data` | Optional | `RaRecord[]` | - | The records. Defaults to the `data` from the [`ListContext`](./useListContext.md) | +| `isPending` | Optional | `boolean` | - | A boolean indicating whether the data is pending. Defaults to the `isPending` from the [`ListContext`](./useListContext.md) | +| `render` | Optional | `(record: RaRecord) => ReactNode` | - | A function that returns the content to render for each record | +| `total` | Optional | `number` | - | The total number of records. Defaults to the `total` from the [`ListContext`](./useListContext.md) | + +Either `children` or `render` is required. + +## `children` + +If provided, `RecordsIterator` will render the `children` prop once for each record, inside a [`RecordContext`](./useRecordContext.md). + +{% raw %} +```tsx +import { RecordsIterator, useRecordContext } from 'react-admin'; + +const PostList = () => ( +
      + + + +
    +); + +const PostItem = () => { + const record = useRecordContext(); + if (!record) return null; + return
  • {record.title} - {record.views}
  • ; +}; +``` +{% endraw %} + +**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. + +This is useful for advanced scenarios where you need direct access to the record data or want to implement custom layouts. + +## `data` + +Although `` reads the data from the closest [``](./useListContext.md), you may provide it yourself when no such context is available: + +{% raw %} +```jsx +import { RecordsIterator, TextField } from 'react-admin'; +import { customerSegments } from './customerSegments.json'; + +const PostList = () => ( +
      + +
    • + +
    • +
      +
    +); +``` +{% endraw %} + +## `isPending` + +Although `` reads the `isPending` from the closest [``](./useListContext.md), you may provide it yourself when no such context is available. This is useful when dealing with data not coming from the `dataProvider`: + +{% raw %} +```tsx +import { RecordsIterator } from 'react-admin'; +import { useQuery } from '@tanstack/react-query'; +import { fetchPostAnalytics } from './fetchPostAnalytics'; + +const DashboardMostVisitedPosts = () => { + const { data, isPending } = useQuery({ + queryKey: ['dashboard', 'posts'], + queryFn: fetchPostAnalytics + }); + + return ( +
      +
    • {record.title} - {record.views}
    • } + /> +
    + ); +} +``` +{% endraw %} + +## `render` + +If provided, `RecordsIterator` will call the `render` prop for each record. This is useful when the components you render need the record data to render themselves, or when you want to pass additional props to the rendered component. + +{% raw %} +```tsx +import { ListBase, RecordsIterator } from 'react-admin'; + +const PostList = () => ( + +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +); +``` +{% endraw %} + +**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. + +## `total` + +Although `` reads the total from the closest [``](./useListContext), you may provide it yourself when no such context is available: + +{% raw %} +```jsx +import { RecordsIterator, TextField } from 'react-admin'; +import { customerSegments } from './customerSegments.json'; + +const PostList = () => ( +
      + +
    • + +
    • +
      +
    +); +``` +{% endraw %} diff --git a/docs/ReferenceArrayFieldBase.md b/docs/ReferenceArrayFieldBase.md index 16befd6e754..713f61939b8 100644 --- a/docs/ReferenceArrayFieldBase.md +++ b/docs/ReferenceArrayFieldBase.md @@ -45,15 +45,17 @@ A typical `post` record therefore looks like this: In that case, use `` to display the post tag names as a list of chips, as follows: ```jsx -import { ListBase, ListIterator, ReferenceArrayFieldBase } from 'react-admin'; +import { ListBase, RecordsIterator, ReferenceArrayFieldBase, WithListContext } from 'react-admin'; export const PostList = () => ( - - - - - + + + + + + + ); diff --git a/docs/ReferenceFieldBase.md b/docs/ReferenceFieldBase.md index 90aba0c615a..3e575c442a2 100644 --- a/docs/ReferenceFieldBase.md +++ b/docs/ReferenceFieldBase.md @@ -223,15 +223,17 @@ When used in a ``, `` fetches the referenced reco For instance, with this code: ```jsx -import { ListBase, ListIterator, ReferenceFieldBase } from 'react-admin'; +import { ListBase, RecordsIterator, ReferenceFieldBase, WithListContext } from 'react-admin'; export const PostList = () => ( - - - - - + + + + + + + ); ``` @@ -270,16 +272,16 @@ For example, the following code prefetches the authors referenced by the posts: ```jsx const PostList = () => ( - ( + + (
    -

    {title}

    +

    {author.title}

    - )} - /> + )} /> +
    ); ``` @@ -303,4 +305,4 @@ 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 +And the link property of the referenceField context will be set accordingly. It will be set to false if the access is denied. diff --git a/docs/ReferenceManyFieldBase.md b/docs/ReferenceManyFieldBase.md index e984ac61e4c..d92217b0bd5 100644 --- a/docs/ReferenceManyFieldBase.md +++ b/docs/ReferenceManyFieldBase.md @@ -72,15 +72,17 @@ const BookList = ({ You can also use `` 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'; +import { ListBase, RecordsIterator, ReferenceManyFieldBase, WithListContext } from 'react-admin'; export const PostList = () => ( - - - - - + + + + + + + ); ``` @@ -116,20 +118,31 @@ export const PostList = () => ( - [``](./EditableDatagrid.md) - [``](./Calendar.md) -For instance, use a `` to render the related records: +For instance, use a `` to render the related records: ```jsx -import { ShowBase, ReferenceManyFieldBase, ListIterator } from 'react-admin'; +import { ShowBase, RecordsIterator, ReferenceManyFieldBase } from 'react-admin'; export const AuthorShow = () => ( - + Loading...

    } + error={null} + offline={null} + empty={null} + >
      - ( -
    • - {book.title}, published on{' '}{book.published_at} -
    • - )}/> + ( +
    • + {book.title}, published on + {book.published_at} +
    • + )} + />
    @@ -343,8 +356,12 @@ In the example below, both lists use the same reference ('books'), but their sel queryOptions={{ meta: { foo: 'bar' }, }} + loading={

    Loading...

    } + error={null} + offline={null} + empty={

    No books

    } > - ( + (

    {book.title}

    )} />
    diff --git a/docs/WithListContext.md b/docs/WithListContext.md index 545f36d0eba..bf9d79fc0ca 100644 --- a/docs/WithListContext.md +++ b/docs/WithListContext.md @@ -12,13 +12,13 @@ Use it to render a list of records already fetched. ## Usage -The most common use case for `` is to build a custom list view on-the-fly, without creating a new component, in a place where records are available inside a `ListContext`. +The most common use case for `` is to build a custom list view on-the-fly, without creating a new component, in a place where records are available inside a `ListContext`. For instance, a list of book tags fetched via [``](./ReferenceArrayField.md): ```jsx import { List, DataTable, ReferenceArrayField, WithListContext } from 'react-admin'; -import { Chip, Stack } from '@mui/material'; +import { Chip, Stack, Typography } from '@mui/material'; const BookList = () => ( @@ -27,15 +27,18 @@ const BookList = () => ( - ( - !isPending && ( + Loading tags...} + errorElement={Error while loading tags} + empty={No associated tags} + render={({ data }) => ( {data.map(tag => ( ))} - ) - )} /> + )} + />
    @@ -45,10 +48,11 @@ const BookList = () => ( ![List of tags](./img/reference-array-field.png) -The equivalent with `useListContext` would require an intermediate component: +The equivalent with `useListContext` would require an intermediate component, manually handling the loading, error, and empty states: ```jsx import { List, DataTable, ReferenceArrayField, WithListContext } from 'react-admin'; +import { Chip, Stack, Typography } from '@mui/material'; const BookList = () => ( @@ -65,25 +69,164 @@ const BookList = () => ( ); const TagList = () => { - const { isPending, data } = useListContext(); - return isPending - ? null - : ( + const { isPending, error, data, total } = useListContext(); + + if (isPending) { + return Loading tags...; + } + + if (error) { + return Error while loading tags; + } + + if (data == null || data.length === 0 || total === 0) { + return No associated tags; + } + + return ( + + {data.map(tag => ( + + ))} + + ); +}; +``` + +Whether you use `` or `useListContext` is a matter of coding style. + +## Standalone usage + +You can also use `` outside of a `ListContext` by filling `data`, `total`, `error`, and `isPending` properties manually. + +```jsx +import { WithListContext } from 'react-admin'; +import { Chip, Stack, Typography } from '@mui/material'; + +const TagList = ({data, isPending}) => ( + Loading tags...} + empty={No associated tags} + render={({ data }) => ( {data.map(tag => ( ))} - ); -}; + )} + /> +); ``` -Whether you use `` or `useListContext` is a matter of coding style. - ## Props `` accepts a single `render` prop, which should be a function. +| Prop | Required | Type | Default | Description | +|----------------|----------|----------------|---------|-------------------------------------------------------------------------------------------| +| `children` | Optional | `ReactNode` | | The components rendered in the list context. | +| `data` | Optional | `RecordType[]` | | The list data in standalone usage. | +| `empty` | Optional | `ReactNode` | | The component to display when the data is empty. | +| `error` | Optional | `Error` | | The error in standalone usage. | +| `errorElement` | Optional | `ReactNode` | | The component to display in case of error. | +| `isPending` | Optional | `boolean` | | Determine if the list is loading in standalone usage. | +| `loading` | Optional | `ReactNode` | | The component to display while checking authorizations. | +| `offline` | Optional | `ReactNode` | | The component to display when there is no connectivity to load data and no data in cache. | +| `render` | Required | `function` | | The function to render the data | +| `total` | Optional | `number` | | The total number of data in the list in standalone usage. | + +## `empty` + +Use `empty` to display a message when the list is empty. + +If `empty` is not provided, the render function will be called with empty data. + +```jsx +no books

    } + render={({ data }) => ( +
      + {data.map(book => ( +
    • + {book.title}, published on + {book.published_at} +
    • + ))} +
    + )} + loading={

    Loading...

    } +/> +``` + +## `errorElement` + +Use `errorElement` to display a message when an error is thrown. + +If `errorElement` is not provided, the render function will be called with the error. + +```jsx +Error while loading books...

    } + render={({ data }) => ( +
      + {data.map(book => ( +
    • + {book.title}, published on + {book.published_at} +
    • + ))} +
    + )} + loading={

    Loading...

    } +/> +``` + +## `loading` + +Use `loading` to display a loader while data is loading. + +If `loading` is not provided, the render function will be called with `isPending` as true and no data. + +```jsx +loading...

    } + render={({ data }) => ( +
      + {data.map(book => ( +
    • + {book.title}, published on + {book.published_at} +
    • + ))} +
    + )} +/> +``` + +## `offline` + +Use `offline` to display a component when there is no connectivity to load data and no data in cache. + +If `offline` is not provided, the render function will be called with `isPaused` as true and no data. + +```jsx +Offline

    } + render={({ data }) => ( +
      + {data.map(book => ( +
    • + {book.title}, published on + {book.published_at} +
    • + ))} +
    + )} +/> +``` + ## `render` A function which will be called with the current [`ListContext`](./useListContext.md) as argument. It should return a React element. @@ -230,4 +373,4 @@ const RefreshListButton = () => ( )} /> ); -``` \ No newline at end of file +``` diff --git a/docs_headless/astro.config.mjs b/docs_headless/astro.config.mjs index d0d657401ff..36ed2504f1b 100644 --- a/docs_headless/astro.config.mjs +++ b/docs_headless/astro.config.mjs @@ -97,7 +97,7 @@ export default defineConfig({ 'filteringtutorial', 'listbase', 'infinitelistbase', - 'listiterator', + 'recordsiterator', 'filterliveform', 'withlistcontext', 'uselist', diff --git a/docs_headless/src/content/docs/CustomRoutes.md b/docs_headless/src/content/docs/CustomRoutes.md index 68c2a7d93ca..ab200f0c291 100644 --- a/docs_headless/src/content/docs/CustomRoutes.md +++ b/docs_headless/src/content/docs/CustomRoutes.md @@ -189,7 +189,7 @@ const App = () => ( // in src/BookList.js import { useParams } from 'react-router-dom'; -import { ListBase, ListIterator } from 'ra-core'; +import { ListBase, RecordsIterator } from 'ra-core'; const BookList = () => { const { authorId } = useParams(); @@ -198,7 +198,7 @@ const BookList = () => {

    Books

      - (
    • {book.title} ({book.year}) @@ -214,4 +214,4 @@ const BookList = () => { **Tip**: In the above example, the `resource="books"` prop is required in `` because the `ResourceContext` defaults to `authors` inside the ``. -Check [the `` element documentation](./Resource.md#children) for more information. \ No newline at end of file +Check [the `` element documentation](./Resource.md#children) for more information. diff --git a/docs_headless/src/content/docs/FieldsForRelationships.md b/docs_headless/src/content/docs/FieldsForRelationships.md index e47122371c3..b28ec1698b8 100644 --- a/docs_headless/src/content/docs/FieldsForRelationships.md +++ b/docs_headless/src/content/docs/FieldsForRelationships.md @@ -134,7 +134,7 @@ const BookShow = () => ( This is fine, but what if you need to display the author details for a list of books? ```jsx -import { ListBase, ReferenceFieldBase, ListIterator } from 'ra-core'; +import { ListBase, ReferenceFieldBase, RecordsIterator } from 'ra-core'; import { TextField } from './TextField'; import { DateField } from './DateField'; import { FunctionField } from './FunctionField'; @@ -152,7 +152,7 @@ const BookList = () => ( - + @@ -167,7 +167,7 @@ const BookList = () => ( - +
    @@ -197,7 +197,7 @@ This field fetches a one-to-many relationship, e.g. the books of an author, when Here is an example usage: ```jsx -import { ShowBase, ReferenceManyFieldBase, ListIterator } from 'ra-core'; +import { ShowBase, ReferenceManyFieldBase, RecordsIterator } from 'ra-core'; import { TextField } from './TextField'; import { DateField } from './DateField'; @@ -209,12 +209,12 @@ const AuthorShow = () => (
      - +
    • -
      +
    @@ -253,7 +253,7 @@ This field fetches a one-to-many relationship, e.g. the books of an author, when Here is an example usage: ```jsx -import { ShowBase, ReferenceArrayFieldBase, ListIterator } from 'ra-core'; +import { ShowBase, ReferenceArrayFieldBase, RecordsIterator } from 'ra-core'; import { TextField } from './TextField'; import { DateField } from './DateField'; @@ -265,12 +265,12 @@ const AuthorShow = () => (
      - +
    • -
      +
    @@ -285,7 +285,7 @@ const AuthorShow = () => ( You can also use it in a List page: ```jsx -import { ListBase, ReferenceArrayFieldBase, ListIterator } from 'ra-core'; +import { ListBase, ReferenceArrayFieldBase, RecordsIterator } from 'ra-core'; import { TextField } from './TextField'; import { DateField } from './DateField'; @@ -302,7 +302,7 @@ const AuthorList = () => ( - + @@ -310,16 +310,16 @@ const AuthorList = () => (
      - +
    • -
      +
    -
    +
    diff --git a/docs_headless/src/content/docs/ListIterator.md b/docs_headless/src/content/docs/ListIterator.md deleted file mode 100644 index 0e407e67ade..00000000000 --- a/docs_headless/src/content/docs/ListIterator.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -title: "" ---- - -## Usage - -Use the `` component to render a list of records in a custom way. Pass a `render` function to customize how each record is displayed. - -```jsx -import { ListBase, ListIterator } from 'ra-core'; - -const MostVisitedPosts = () => ( - -
      -
    • {record.title} - {record.views}
    • } - /> -
    -
    -); -``` - -You can use `` as a child of any component that provides a [`ListContext`](./useListContext.md), such as: - -- [``](./ListBase.md) -- [``](./InfiniteListBase.md) -- [``](./ReferenceArrayFieldBase.md) - -**Tip**: Since this is the headless version, you have full control over how to render lists. `` is a low-level component that helps you iterate over records while respecting the loading and error states from the `ListContext`. - -## Props - -Here are all the props you can set on the `` component: - -| Prop | Required | Type | Default | Description | -| ----------- | -------- | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | -| `children` | Optional | `ReactNode` | - | The content to render for each record | -| `data` | Optional | `RaRecord[]` | - | The records. Defaults to the `data` from the `ListContext` | -| `empty` | Optional | `ReactNode` | `null` | The content to display when there is no data | -| `error` | Optional | `ReactNode` | `null` | The content to display when the data fetching fails | -| `isPending` | Optional | `boolean` | - | A boolean indicating whether the data is pending. Defaults to the `isPending` from the `ListContext` | -| `loading` | Optional | `ReactNode` | `null` | The content to display while the data is loading | -| `render` | Optional | `(record: RaRecord) => ReactNode` | - | A function that returns the content to render for each record | -| `total` | Optional | `number` | - | The total number of records. Defaults to the `total` from the `ListContext` | - -Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform). - -## `children` - -If provided, `ListIterator` will render the `children` prop once for each record, inside a [`RecordContext`](./useRecordContext.md). - -```tsx -import { ListIterator, useRecordContext } from 'ra-core'; - -const PostList = () => ( -
      - - - -
    -); - -const PostItem = () => { - const record = useRecordContext(); - if (!record) return null; - return
  • {record.title} - {record.views}
  • ; -}; -``` - -**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. - -## `data` - -Although `` reads the data from the closest [``](./useListContext), you may provide it yourself when no such context is available: - -```jsx -import { ListIterator } from 'ra-core'; -import { customerSegments } from './customerSegments.json'; - -const PostList = () => ( -
      -
    • {record.name}
    • } - /> -
    -); -``` - -## `empty` - -To provide a custom UI when there is no data, use the `empty` prop. - -```jsx -import { ListBase, ListIterator } from 'ra-core'; - -const PostList = () => ( -
      - No posts found} - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` - -## `error` - -To provide a custom UI when the data fetching fails, use the `error` prop. - -```jsx -import { ListIterator } from 'ra-core'; - -const PostList = () => ( -
      - Error loading posts} - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` - -## `isPending` - -Although `` reads the `isPending` from the closest [``](./useListContext), you may provide it yourself when no such context is available. This is useful when dealing with data not coming from the `dataProvider`: - -```tsx -import { ListIterator } from 'ra-core'; -import { useQuery } from '@tanstack/react-query'; -import { fetchPostAnalytics } from './fetchPostAnalytics'; - -const DashboardMostVisitedPosts = () => { - const { data, isPending } = useQuery({ - queryKey: ['dashboard', 'posts'], - queryFn: fetchPostAnalytics - }); - - return ( -
      -
    • {record.title} - {record.views}
    • } - /> -
    - ); -} -``` - - -## `loading` - -To provide a custom UI while the data is loading, use the `loading` prop. - -```jsx -import { ListIterator } from 'ra-core'; - -const PostList = () => ( -
      - Loading posts...} - render={record =>
    • {record.title} - {record.views}
    • } - /> -
    -); -``` - -## `render` - -If provided, `ListIterator` will call the `render` prop for each record. This is useful when the components you render need the record data to render themselves, or when you want to pass additional props to the rendered component. - -```tsx -import { ListIterator } from 'ra-core'; - -const PostList = () => ( -
      -
    • {record.title} - {record.views}
    • } - /> -
    -); -``` - -**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. - -## `total` - -Although `` reads the total from the closest [``](./useListContext), you may provide it yourself when no such context is available: - -```jsx -import { ListIterator } from 'ra-core'; -import { customerSegments } from './customerSegments.json'; - -const PostList = () => ( -
      -
    • {record.name}
    • } - /> -
    -); -``` - diff --git a/docs_headless/src/content/docs/RecordsIterator.md b/docs_headless/src/content/docs/RecordsIterator.md new file mode 100644 index 00000000000..2da1ff4e8d4 --- /dev/null +++ b/docs_headless/src/content/docs/RecordsIterator.md @@ -0,0 +1,194 @@ +--- +title: "" +storybook_path: ra-core-controller-list-recordsiterator +--- + +Use the `` component to render a list of records in a custom way. Pass a `render` function to customize how each record is displayed. Pass a `data` prop to use it out of a list context. + +## Usage + +Use `` inside a [`ListContext`](./useListContext.md) to render each record: + +```jsx +import { ListBase, RecordsIterator } from 'ra-core'; + +const MostVisitedPosts = () => ( + +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +); +``` + +You can use `` as a child of any component that provides a [`ListContext`](./useListContext.md), such as: + +- [``](./List.md), +- [``](./ListGuesser.md), +- [``](./ListBase.md), +- [``](./ReferenceArrayField.md), +- [``](./ReferenceManyField.md) + +`` expects that data is properly loaded, without error. If you want to handle loading, error, offline and empty states, use properties on the component providing you the list context (like [``](./List.md), [``](./ReferenceArrayField.md), [``](./ReferenceManyField.md)). You can also make use of [``](./WithListContext.md) [`loading`](./WithListContext.md#loading), [`errorElement`](./WithListContext.md#errorelement), [`offline`](./WithListContext.md#offline) and [`empty`](./WithListContext.md#empty) props. + +```jsx +import { ListBase, RecordsIterator, WithListContext } from 'ra-core'; + +const MostVisitedPosts = () => ( + + Loading...

    } + errorElement={

    Something went wrong

    } + empty={

    No posts found

    } + offline={

    You are offline

    } + > +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +
    +); +``` + +## Props + +`` exposes the following important props: + +| Prop | Required | Type | Default | Description | +| ----------- | -------- |-----------------------------------| ------- | ---------------------------------------------------------------------------------------------------- | +| `children` | Optional | `ReactNode` | - | The content to render for each record | +| `data` | Optional | `RaRecord[]` | - | The records. Defaults to the `data` from the [`ListContext`](./useListContext.md) | +| `isPending` | Optional | `boolean` | - | A boolean indicating whether the data is pending. Defaults to the `isPending` from the [`ListContext`](./useListContext.md) | +| `render` | Optional | `(record: RaRecord) => ReactNode` | - | A function that returns the content to render for each record | +| `total` | Optional | `number` | - | The total number of records. Defaults to the `total` from the [`ListContext`](./useListContext.md) | + +Either `children` or `render` is required. + +## `children` + +If provided, `RecordsIterator` will render the `children` prop once for each record, inside a [`RecordContext`](./useRecordContext.md). + +```tsx +import { RecordsIterator, useRecordContext } from 'ra-core'; + +const PostList = () => ( +
      + + + +
    +); + +const PostItem = () => { + const record = useRecordContext(); + if (!record) return null; + return
  • {record.title} - {record.views}
  • ; +}; +``` + +**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. + +This is useful for advanced scenarios where you need direct access to the record data or want to implement custom layouts. + +## `data` + +Although `` reads the data from the closest [``](./useListContext.md), you may provide it yourself when no such context is available: + +```jsx +import { RecordsIterator, TextField } from 'ra-core'; +import { customerSegments } from './customerSegments.json'; + +const PostList = () => ( +
      + +
    • + +
    • +
      +
    +); +``` + +## `isPending` + +Although `` reads the `isPending` from the closest [``](./useListContext.md), you may provide it yourself when no such context is available. This is useful when dealing with data not coming from the `dataProvider`: + +```tsx +import { RecordsIterator } from 'ra-core'; +import { useQuery } from '@tanstack/react-query'; +import { fetchPostAnalytics } from './fetchPostAnalytics'; + +const DashboardMostVisitedPosts = () => { + const { data, isPending } = useQuery({ + queryKey: ['dashboard', 'posts'], + queryFn: fetchPostAnalytics + }); + + return ( +
      +
    • {record.title} - {record.views}
    • } + /> +
    + ); +} +``` + +## `render` + +If provided, `RecordsIterator` will call the `render` prop for each record. This is useful when the components you render need the record data to render themselves, or when you want to pass additional props to the rendered component. + +```tsx +import { ListBase, RecordsIterator } from 'ra-core'; + +const PostList = () => ( + +
      +
    • {record.title} - {record.views}
    • } + /> +
    +
    +); +``` + +**Note**: You can't provide both the `children` and the `render` props. If both are provided, `` will use the `render` prop. + +## `total` + +Although `` reads the total from the closest [``](./useListContext), you may provide it yourself when no such context is available: + +```jsx +import { RecordsIterator, TextField } from 'ra-core'; +import { customerSegments } from './customerSegments.json'; + +const PostList = () => ( +
      + +
    • + +
    • +
      +
    +); +``` diff --git a/docs_headless/src/content/docs/ReferenceArrayFieldBase.md b/docs_headless/src/content/docs/ReferenceArrayFieldBase.md index b18e3cd8826..45aaf5c1192 100644 --- a/docs_headless/src/content/docs/ReferenceArrayFieldBase.md +++ b/docs_headless/src/content/docs/ReferenceArrayFieldBase.md @@ -41,15 +41,17 @@ A typical `post` record therefore looks like this: In that case, use `` to display the post tag names as a list of chips, as follows: ```jsx -import { ListBase, ListIterator, ReferenceArrayFieldBase } from 'ra-core'; +import { ListBase, RecordsIterator, ReferenceArrayFieldBase, WithListContext } from 'ra-core'; export const PostList = () => ( - - - - - + + + + + + + ); diff --git a/docs_headless/src/content/docs/ReferenceFieldBase.md b/docs_headless/src/content/docs/ReferenceFieldBase.md index 26ec34459ca..364c50dd36d 100644 --- a/docs_headless/src/content/docs/ReferenceFieldBase.md +++ b/docs_headless/src/content/docs/ReferenceFieldBase.md @@ -216,15 +216,17 @@ When used in a list, `` fetches the referenced record only o For instance, with this code: ```jsx -import { ListBase, ListIterator, ReferenceFieldBase } from 'ra-core'; +import { ListBase, RecordsIterator, ReferenceFieldBase, WithListContext } from 'ra-core'; export const PostList = () => ( - - - - - + + + + + + + ); ``` @@ -262,16 +264,16 @@ For example, the following code prefetches the authors referenced by the posts: ```jsx const PostList = () => ( - ( + + (
    -

    {title}

    +

    {author.title}

    - )} - /> + )} /> +
    ); ``` @@ -294,4 +296,4 @@ 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 +And the link property of the referenceField context will be set accordingly. It will be set to false if the access is denied. diff --git a/docs_headless/src/content/docs/ReferenceManyCountBase.md b/docs_headless/src/content/docs/ReferenceManyCountBase.md index 33c055e8623..2ff527f59a2 100644 --- a/docs_headless/src/content/docs/ReferenceManyCountBase.md +++ b/docs_headless/src/content/docs/ReferenceManyCountBase.md @@ -16,7 +16,7 @@ For instance, to display the number of comments related to a post in a List view ```jsx import { ListBase, - ListIterator, + RecordsIterator, ReferenceManyCountBase, } from 'ra-core'; import { TextField } from './TextField'; @@ -34,7 +34,7 @@ export const PostList = () => ( - + @@ -46,7 +46,7 @@ export const PostList = () => ( /> - +
    diff --git a/docs_headless/src/content/docs/ReferenceManyFieldBase.md b/docs_headless/src/content/docs/ReferenceManyFieldBase.md index 49a83cb76a7..ca0df6d1891 100644 --- a/docs_headless/src/content/docs/ReferenceManyFieldBase.md +++ b/docs_headless/src/content/docs/ReferenceManyFieldBase.md @@ -69,15 +69,17 @@ const BookList = ({ You can also use `` 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 'ra-core'; +import { ListBase, RecordsIterator, ReferenceManyFieldBase, WithListContext } from 'ra-core'; export const PostList = () => ( - - - - - + + + + + + + ); ``` @@ -106,20 +108,31 @@ export const PostList = () => ( `` renders its children inside a [`ListContext`](./useListContext.md). This means you can use any list iterator component as child. -For instance, use a `` to render the related records: +For instance, use a `` to render the related records: ```jsx -import { ShowBase, ReferenceManyFieldBase, ListIterator } from 'ra-core'; +import { ShowBase, ReferenceManyFieldBase, RecordsIterator } from 'ra-core'; export const AuthorShow = () => ( - + Loading...

    } + error={null} + offline={null} + empty={null} + >
      - ( -
    • - {book.title}, published on{' '}{book.published_at} -
    • - )}/> + ( +
    • + {book.title}, published on + {book.published_at} +
    • + )} + />
    @@ -326,8 +339,12 @@ In the example below, both lists use the same reference ('books'), but their sel queryOptions={{ meta: { foo: 'bar' }, }} + loading={

    Loading...

    } + error={null} + offline={null} + empty={

    No books

    } > - ( + (

    {book.title}

    )} />
    diff --git a/docs_headless/src/content/docs/Resource.md b/docs_headless/src/content/docs/Resource.md index 81b0b79de1b..c9f434968cc 100644 --- a/docs_headless/src/content/docs/Resource.md +++ b/docs_headless/src/content/docs/Resource.md @@ -132,7 +132,7 @@ The `BookList` component can grab the `authorId` parameter from the URL using th ```jsx // in src/BookList.jsx -import { ListBase, ListIterator } from 'ra-core'; +import { ListBase, RecordsIterator } from 'ra-core'; import { useParams } from 'react-router-dom'; export const BookList = () => { @@ -142,7 +142,7 @@ export const BookList = () => {

    Books

      - (
    • {book.title} ({book.year}) @@ -162,7 +162,7 @@ It's your responsibility to route to the `/authors/:id/books` route, e.g. from e ```jsx // in src/AuthorList.jsx -import { useRecordContext, ListBase, ListIterator } from 'ra-core'; +import { useRecordContext, ListBase, RecordsIterator } from 'ra-core'; import { Link } from 'react-router-dom'; const BooksButton = () => { @@ -178,7 +178,7 @@ export const AuthorList = () => (

      Authors

      - (
      {author.firstName} {author.lastName} @@ -297,7 +297,7 @@ For instance, the following component displays the name of the current resource: ```jsx import * as React from 'react'; -import { ListBase, ListIterator, useResourceContext } from 'ra-core'; +import { ListBase, RecordsIterator, useResourceContext } from 'ra-core'; const ResourceName = () => { const resource = useResourceContext(); @@ -308,7 +308,7 @@ const PostList = () => (
      {/* renders 'posts' */} - (

      {record.title}

      @@ -366,7 +366,7 @@ In order to display a list of songs for the selected artist, `` should ```jsx // in src/SongList.jsx -import { ListBase, ListIterator, useRecordContext } from 'ra-core'; +import { ListBase, RecordsIterator, useRecordContext } from 'ra-core'; import { useParams, Link } from 'react-router-dom'; export const SongList = () => { @@ -375,7 +375,7 @@ export const SongList = () => {

      Songs

      - (

      {song.title}

      diff --git a/docs_headless/src/content/docs/WithListContext.md b/docs_headless/src/content/docs/WithListContext.md index 5a815656bd2..1418d45d240 100644 --- a/docs_headless/src/content/docs/WithListContext.md +++ b/docs_headless/src/content/docs/WithListContext.md @@ -24,8 +24,11 @@ const BookList = () => ( - ( - !isPending && ( + Loading tags...

      } + errorElement={

      Error while loading tags

      } + empty={

      No associated tags

      } + render={({ data }) => (
      {data.map(tag => ( @@ -33,8 +36,8 @@ const BookList = () => ( ))}
      - ) - )} /> + )} + />
      @@ -44,7 +47,7 @@ const BookList = () => ( ![List of tags](../../img/reference-array-field.png) -The equivalent with `useListContext` would require an intermediate component: +The equivalent with `useListContext` would require an intermediate component, manually handling the loading, error, and empty states: ```jsx import { ListBase, useListContext, ReferenceArrayFieldBase } from 'ra-core'; @@ -65,10 +68,48 @@ const BookList = () => ( ); const TagList = () => { - const { isPending, data } = useListContext(); - return isPending - ? null - : ( + const { isPending, error, data, total } = useListContext(); + + if (isPending) { + return

      Loading tags...

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

      Error while loading tags

      ; + } + + if (data == null || data.length === 0 || total === 0) { + return

      No associated tags

      ; + } + + return ( +
      + {data.map(tag => ( + + {tag.name} + + ))} +
      + ); +}; +``` + +Whether you use `` or `useListContext` is a matter of coding style. + +## Standalone usage + +You can also use `` outside of a `ListContext` by filling `data`, `total`, `error`, and `isPending` properties manually. + +```jsx +import { WithListContext } from 'react-admin'; + +const TagList = ({data, isPending}) => ( + Loading tags...

      } + empty={

      No associated tags

      } + render={({ data }) => (
      {data.map(tag => ( @@ -76,16 +117,118 @@ const TagList = () => { ))}
      - ); -}; + )} + /> +); ``` -Whether you use `` or `useListContext` is a matter of coding style. - ## Props `` accepts a single `render` prop, which should be a function. +| Prop | Required | Type | Default | Description | +|----------------|----------|----------------|---------|-------------------------------------------------------------------------------------------| +| `children` | Optional | `ReactNode` | | The components rendered in the list context. | +| `data` | Optional | `RecordType[]` | | The list data in standalone usage. | +| `empty` | Optional | `ReactNode` | | The component to display when the data is empty. | +| `error` | Optional | `Error` | | The error in standalone usage. | +| `errorElement` | Optional | `ReactNode` | | The component to display in case of error. | +| `isPending` | Optional | `boolean` | | Determine if the list is loading in standalone usage. | +| `loading` | Optional | `ReactNode` | | The component to display while checking authorizations. | +| `offline` | Optional | `ReactNode` | | The component to display when there is no connectivity to load data and no data in cache. | +| `render` | Required | `function` | | The function to render the data | +| `total` | Optional | `number` | | The total number of data in the list in standalone usage. | + +## `empty` + +Use `empty` to display a message when the list is empty. + +If `empty` is not provided, the render function will be called with empty data. + +```jsx +no books

      } + render={({ data }) => ( +
        + {data.map(book => ( +
      • + {book.title}, published on + {book.published_at} +
      • + ))} +
      + )} + loading={

      Loading...

      } +/> +``` + +## `errorElement` + +Use `errorElement` to display a message when an error is thrown. + +If `errorElement` is not provided, the render function will be called with the error. + +```jsx +Error while loading books...

      } + render={({ data }) => ( +
        + {data.map(book => ( +
      • + {book.title}, published on + {book.published_at} +
      • + ))} +
      + )} + loading={

      Loading...

      } +/> +``` + +## `loading` + +Use `loading` to display a loader while data is loading. + +If `loading` is not provided, the render function will be called with `isPending` as true and no data. + +```jsx +loading...

      } + render={({ data }) => ( +
        + {data.map(book => ( +
      • + {book.title}, published on + {book.published_at} +
      • + ))} +
      + )} +/> +``` + +## `offline` + +Use `offline` to display a component when there is no connectivity to load data and no data in cache. + +If `offline` is not provided, the render function will be called with `isPaused` as true and no data. + +```jsx +Offline

      } + render={({ data }) => ( +
        + {data.map(book => ( +
      • + {book.title}, published on + {book.published_at} +
      • + ))} +
      + )} +/> +``` + ## `render` A function which will be called with the current [`ListContext`](./useListContext.md) as argument. It should return a React element. diff --git a/docs_headless/src/content/docs/WithRecord.md b/docs_headless/src/content/docs/WithRecord.md index 7df2117e529..071aad0c8f9 100644 --- a/docs_headless/src/content/docs/WithRecord.md +++ b/docs_headless/src/content/docs/WithRecord.md @@ -28,7 +28,7 @@ As soon as there is a record available, ra-core puts it in a `RecordContext`. Th - in descendants of the `` component - in descendants of the `` component - in descendants of the `` component -- in descendants of the `` component +- in descendants of the `` component ## TypeScript diff --git a/docs_headless/src/content/docs/useRecordContext.md b/docs_headless/src/content/docs/useRecordContext.md index f56aa9886cb..812c9c6858d 100644 --- a/docs_headless/src/content/docs/useRecordContext.md +++ b/docs_headless/src/content/docs/useRecordContext.md @@ -53,7 +53,7 @@ As soon as there is a record available, ra-core puts it in a `RecordContext`. Th - in descendants of the `` component - in descendants of the `` component - in descendants of the `` component -- in descendants of the `` component +- in descendants of the `` component - in descendants of the `` component ## Inside A Form diff --git a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx index 975f3a1a76f..8a736e7e1b2 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyFieldBase.stories.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; import { onlineManager, QueryClient } from '@tanstack/react-query'; +import { RecordsIterator, WithListContext } from 'ra-core'; 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 { ListBase, useListContext } from '../list'; import fakeRestDataProvider from 'ra-data-fakerest'; import { useIsOffline } from '../../core'; @@ -134,20 +135,27 @@ export const InAList = ({ dataProvider = dataProviderWithAuthorList }) => ( name="authors" list={ - ( -
      -

      {author.last_name} Books

      - - - -
      - )} - >
      + + ( +
      +

      {author.last_name} Books

      + + + +
      + )} + /> +
      } /> diff --git a/packages/ra-core/src/controller/list/ListIterator.tsx b/packages/ra-core/src/controller/list/ListIterator.tsx deleted file mode 100644 index eb93a932c11..00000000000 --- a/packages/ra-core/src/controller/list/ListIterator.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react'; -import { RaRecord } from '../../types'; -import { useListContextWithProps } from './useListContextWithProps'; -import { RecordContextProvider } from '../record'; - -export const ListIterator = ( - props: ListIteratorProps -) => { - const { children, empty, error: errorElement, loading, render } = props; - const { data, total, isPending, error } = - useListContextWithProps(props); - - if (isPending === true) { - return loading ? loading : null; - } - - if (error) { - return errorElement - ? React.cloneElement(errorElement, { error }) - : null; - } - - if (data == null || data.length === 0 || total === 0) { - return empty ? empty : null; - } - - if (!render && !children) { - throw new Error( - ': either `render` or `children` prop must be provided' - ); - } - - return ( - <> - {data.map((record, index) => ( - - {render ? render(record, index) : children} - - ))} - - ); -}; - -export interface ListIteratorProps { - children?: React.ReactNode; - empty?: React.ReactElement; - loading?: React.ReactElement; - error?: React.ReactElement; - render?: (record: RecordType, index: number) => React.ReactNode; - data?: RecordType[]; - total?: number; - isPending?: boolean; -} diff --git a/packages/ra-core/src/controller/list/ListIterator.spec.tsx b/packages/ra-core/src/controller/list/RecordsIterator.spec.tsx similarity index 88% rename from packages/ra-core/src/controller/list/ListIterator.spec.tsx rename to packages/ra-core/src/controller/list/RecordsIterator.spec.tsx index 3ed2b532627..8f3ef06e710 100644 --- a/packages/ra-core/src/controller/list/ListIterator.spec.tsx +++ b/packages/ra-core/src/controller/list/RecordsIterator.spec.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { UsingChildren, UsingRender } from './ListIterator.stories'; +import { UsingChildren, UsingRender } from './RecordsIterator.stories'; -describe('', () => { +describe('', () => { describe.each([ { Story: UsingRender, prop: 'render' }, { Story: UsingChildren, prop: 'children' }, diff --git a/packages/ra-core/src/controller/list/ListIterator.stories.tsx b/packages/ra-core/src/controller/list/RecordsIterator.stories.tsx similarity index 56% rename from packages/ra-core/src/controller/list/ListIterator.stories.tsx rename to packages/ra-core/src/controller/list/RecordsIterator.stories.tsx index ae1fcbc44f9..7898f245493 100644 --- a/packages/ra-core/src/controller/list/ListIterator.stories.tsx +++ b/packages/ra-core/src/controller/list/RecordsIterator.stories.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { useList, UseListOptions } from './useList'; import { ListContextProvider } from './ListContextProvider'; -import { ListIterator } from './ListIterator'; +import { RecordsIterator } from './RecordsIterator'; import { useRecordContext } from '../record'; +import { WithListContext } from './WithListContext'; export default { - title: 'ra-core/controller/list/ListIterator', + title: 'ra-core/controller/list/RecordsIterator', }; const data = [ @@ -33,26 +34,31 @@ export const UsingRender = ({ return ( -
        Loading...
      } + errorElement={
      Error
      } + offline={
      Offline
      } + empty={
      No data
      } > - Loading...
      } - empty={
      No data
      } - render={record => ( -
    • - {record.title} -
    • - )} - /> -
    +
      + ( +
    • + {record.title} +
    • + )} + /> +
    + ); }; @@ -93,18 +99,22 @@ export const UsingChildren = ({ return ( -
      Loading...
    } + errorElement={
    Error
    } + offline={
    Offline
    } + empty={
    No data
    } > - Loading...} - empty={
    No data
    } +
      - - -
    + + + + +
    ); }; diff --git a/packages/ra-core/src/controller/list/RecordsIterator.tsx b/packages/ra-core/src/controller/list/RecordsIterator.tsx new file mode 100644 index 00000000000..476da322c15 --- /dev/null +++ b/packages/ra-core/src/controller/list/RecordsIterator.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { RaRecord } from '../../types'; +import { useListContextWithProps } from './useListContextWithProps'; +import { RecordContextProvider } from '../record'; +import { ListControllerSuccessResult } from './useListController'; + +export const RecordsIterator = ( + props: RecordsIteratorProps +) => { + const { children, render } = props; + const { data, total, isPending, error } = + useListContextWithProps(props); + + if ( + isPending === true || + error || + data == null || + data.length === 0 || + total === 0 + ) { + console.warn( + ' does not handle loading, offline, empty and error states. Use .' + ); + return null; + } + + if (!render && !children) { + return null; + } + + return ( + <> + {data.map((record, index) => ( + + {render ? render(record, index) : children} + + ))} + + ); +}; + +export interface RecordsIteratorProps + extends Partial> { + children?: React.ReactNode; + render?: (record: RecordType, index: number) => React.ReactNode; +} + +/** + * @deprecated use RecordsIterator instead. + */ +export const ListIterator = RecordsIterator; +/** + * @deprecated use RecordsIteratorProps instead. + */ +export type ListIteratorProps = RecordsIteratorProps; diff --git a/packages/ra-core/src/controller/list/WithListContext.spec.tsx b/packages/ra-core/src/controller/list/WithListContext.spec.tsx new file mode 100644 index 00000000000..2dd1dd6733f --- /dev/null +++ b/packages/ra-core/src/controller/list/WithListContext.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { Basic, Empty, Loading, Error } from './WithListContext.stories'; + +describe('WithListContext', () => { + it('should display ', async () => { + render(); + await screen.findByText('Total: 90'); + + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(92); // 90 records + 1 header row + 1 footer row + }); + + it('should display empty when no data', async () => { + render(); + await screen.findByText('No fruits found'); + }); + + it('should display loading when pending', async () => { + render(); + await screen.findByText('Loading...'); + }); + + it('should display error when error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('Error loading data'); + }); +}); diff --git a/packages/ra-core/src/controller/list/WithListContext.stories.tsx b/packages/ra-core/src/controller/list/WithListContext.stories.tsx index 96b0c237d0a..b5fe73c660d 100644 --- a/packages/ra-core/src/controller/list/WithListContext.stories.tsx +++ b/packages/ra-core/src/controller/list/WithListContext.stories.tsx @@ -114,7 +114,10 @@ const fruitsData = fruitscsv.split('\n').map((line, id) => { }; }); -const dataProvider = fakerestDataProvider({ fruits: fruitsData }, true); +const dataProvider = fakerestDataProvider( + { fruits: fruitsData }, + process.env.NODE_ENV !== 'test' +); type Fruit = { id: number; @@ -222,3 +225,137 @@ export const Chart = () => (
    ); + +const emptyDataProvider = fakerestDataProvider( + { fruits: [] }, + process.env.NODE_ENV !== 'test' +); + +export const Empty = () => ( + + + + empty={
    No fruits found
    } + render={({ isPending, data, total }) => + isPending ? ( + <>Loading... + ) : ( + + + + + + + + + + + {data.map(fruit => ( + + + + + + + ))} + + + + + + +
    DateApplesBlueberriesCarrots
    {fruit.date}{fruit.apples}{fruit.blueberries}{fruit.carrots}
    Total: {total}
    + ) + } + /> +
    +
    +); + +const foreverLoadingDataProvider = { + ...dataProvider, + getList: _resource => new Promise(() => {}), +} as any; + +export const Loading = () => ( + + + + loading={<>Loading...} + render={({ data, total }) => ( + + + + + + + + + + + {data.map(fruit => ( + + + + + + + ))} + + + + + + +
    DateApplesBlueberriesCarrots
    {fruit.date}{fruit.apples}{fruit.blueberries}{fruit.carrots}
    Total: {total}
    + )} + /> +
    +
    +); + +const erroredDataProvider = { + ...dataProvider, + getList: _resource => Promise.reject('Error'), +} as any; + +export const Error = () => ( + + + + errorElement={

    Error loading data

    } + render={({ isPending, data, total }) => + isPending ? ( + <>Loading... + ) : ( + + + + + + + + + + + {data.map(fruit => ( + + + + + + + ))} + + + + + + +
    DateApplesBlueberriesCarrots
    {fruit.date}{fruit.apples}{fruit.blueberries}{fruit.carrots}
    Total: {total}
    + ) + } + /> +
    +
    +); diff --git a/packages/ra-core/src/controller/list/WithListContext.tsx b/packages/ra-core/src/controller/list/WithListContext.tsx index a4c51c05bc4..6d6df5d5508 100644 --- a/packages/ra-core/src/controller/list/WithListContext.tsx +++ b/packages/ra-core/src/controller/list/WithListContext.tsx @@ -1,10 +1,10 @@ -import { ReactElement } from 'react'; +import React, { ReactElement } from 'react'; import { RaRecord } from '../../types'; import { ListControllerResult } from './useListController'; -import { useListContext } from './useListContext'; +import { useListContextWithProps } from './useListContextWithProps'; /** - * Render prop version of useListContext + * Render prop version of useListContextWithProps * * @example * const BookList = () => ( @@ -20,13 +20,69 @@ import { useListContext } from './useListContext'; * ); */ export const WithListContext = ({ + empty, + loading, + offline, + errorElement, render, -}: WithListContextProps) => - render(useListContext()) || null; + children, + ...props +}: WithListContextProps) => { + const context = useListContextWithProps(props); + const { data, total, isPaused, isPending, isPlaceholderData, error } = + context; -export interface WithListContextProps { - render: ( - context: ListControllerResult + if (!isPaused && isPending && loading !== false && loading !== undefined) { + return loading; + } + + if ( + isPaused && + (isPending || isPlaceholderData) && + offline !== false && + offline !== undefined + ) { + return offline; + } + + if (error && errorElement !== false && errorElement !== undefined) { + return errorElement; + } + + if ( + (data == null || data.length === 0 || total === 0) && + empty !== false && + empty !== undefined + ) { + return empty; + } + + if (render) { + return render(context); + } + + return children; +}; + +export interface WithListContextProps + extends React.PropsWithChildren< + Partial< + Pick< + ListControllerResult, + 'data' | 'total' | 'isPending' | 'error' + > + > + > { + render?: ( + context: Partial> ) => ReactElement | false | null; + loading?: React.ReactNode; + offline?: React.ReactNode; + errorElement?: React.ReactNode; + empty?: React.ReactNode; + + /** + * @deprecated + */ label?: string; } diff --git a/packages/ra-core/src/controller/list/index.ts b/packages/ra-core/src/controller/list/index.ts index cd87e89bfc3..3f0fe7feb26 100644 --- a/packages/ra-core/src/controller/list/index.ts +++ b/packages/ra-core/src/controller/list/index.ts @@ -6,10 +6,10 @@ export * from './ListContext'; export * from './ListContextProvider'; export * from './ListController'; export * from './ListFilterContext'; -export * from './ListIterator'; export * from './ListPaginationContext'; export * from './ListSortContext'; export * from './queryReducer'; +export * from './RecordsIterator'; export * from './useExpanded'; export * from './useFilterContext'; export * from './useInfiniteListController'; diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx index 2db79e94d09..91b6d6c3057 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx @@ -262,7 +262,8 @@ describe('', () => { it('should be customized by a theme', async () => { render(); - expect(screen.queryByTestId('themed-list').classList).toContain( + await screen.findByText('War and Peace'); + expect(screen.getByTestId('themed-list').classList).toContain( 'custom-class' ); }); diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx index 7096b5b44d5..89559ce87ba 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx @@ -7,7 +7,7 @@ import { TestMemoryRouter, ResourceContextProvider, ResourceProps, - ResourceDefinitionContextProvider, + ListBase, } from 'ra-core'; import defaultMessages from 'ra-language-english'; import polyglotI18nProvider from 'ra-i18n-polyglot'; @@ -116,17 +116,18 @@ const data = { ], }; +const myDataProvider = fakeRestDataProvider(data); + export const Basic = () => ( - - + + record.title} secondaryText={record => record.author} tertiaryText={record => record.year} /> - +
    ); @@ -139,28 +140,16 @@ export const LinkType = ({ locationCallback?: (l: Location) => void; }) => ( - - - - Inferred should target edit - record.title} - secondaryText={record => record.author} - tertiaryText={record => record.year} - linkType={linkType} - /> - - + + + Inferred should target edit + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + linkType={linkType} + /> + ); @@ -191,28 +180,16 @@ export const RowClick = ({ rowClick: string | RowClickFunction | false; }) => ( - - - - Inferred should target edit - record.title} - secondaryText={record => record.author} - tertiaryText={record => record.year} - rowClick={rowClick} - /> - - + + + Inferred should target edit + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + rowClick={rowClick} + /> + ); @@ -237,8 +214,6 @@ RowClick.argTypes = { }, }; -const myDataProvider = fakeRestDataProvider(data); - const Wrapper = ({ children, dataProvider = myDataProvider, @@ -443,6 +418,7 @@ export const StandaloneEmpty = () => ( export const Themed = () => ( ( }, } as ThemeOptions)} > - + record.title} secondaryText={record => record.author} tertiaryText={record => record.year} /> - + ); diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index afb4c1d69d8..9f98c70dd29 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -14,13 +14,13 @@ import { } from '@mui/material/styles'; import { type RaRecord, - RecordContextProvider, + RecordsIterator, sanitizeListRestProps, useGetRecordRepresentation, - useListContextWithProps, useRecordContext, useResourceContext, useTranslate, + WithListContext, } from 'ra-core'; import * as React from 'react'; import { isValidElement, type ReactElement } from 'react'; @@ -97,56 +97,51 @@ export const SimpleList = ( resource, ...rest } = props; - const { data, isPending, total } = - useListContextWithProps(props); - - if (isPending === true) { - return ( - - ); - } - - if (data == null || data.length === 0 || total === 0) { - if (empty) { - return empty; - } - - return null; - } return ( - - {data.map((record, rowIndex) => ( - - - - - - ))} - + + } + empty={empty ?? null} + // We need to keep passing data explicitly as it may have been passed down through props. + render={({ data }) => ( + + ( + + + + )} + /> + + )} + /> ); }; diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index 7c2a0ea34e1..0c45f818b89 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -28,7 +28,7 @@ import { Link } from '../Link'; * * -* @example Choose the field to be used as text label + * @example Choose the field to be used as text label * * *