diff --git a/.lintstagedrc b/.lintstagedrc index 9add92435b4..540cf7baa4b 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,10 +1,8 @@ { "*.{js,jsx,ts,tsx}": [ - "eslint --fix", - "git add", + "eslint --fix" ], "*.{json,css,md}": [ - "prettier", - "git add" + "prettier" ] } \ No newline at end of file diff --git a/docs/ListIterator.md b/docs/ListIterator.md new file mode 100644 index 00000000000..2ae9209f266 --- /dev/null +++ b/docs/ListIterator.md @@ -0,0 +1,229 @@ +--- +layout: default +title: "ListIterator" +storybook_path: ra-core-controller-list-listiterator--using-render +--- + +# `` + +## Usage + +Use the `` component as a child of any component that provides a [`ListContext`](./useListContext.md): + +- ``, +- ``, +- ``, +- ``, +- `` + +{% raw %} +```jsx +import { ListBase, ListIterator } from 'react-admin'; +import { OrderedList, ListItem } from 'my-favorite-ui-lib'; + +const DashboardMostVisitedPosts = () => ( + + + {record.title} - {record.views}} + /> + + +); +``` +{% endraw %} + +## 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 | +| `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 for each record. This is useful when the components you render leverages the [`RecordContext`](./useRecordContext.md): + +{% raw %} +```tsx +import { ListBase, ListIterator, useRecordContext } from 'react-admin'; +import { OrderedList, ListItem } from 'my-favorite-ui-lib'; + +const DashboardMostVisitedPosts = () => ( + + + + + + + +); + +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 { OrderedList, ListItem } from 'my-favorite-ui-lib'; +import { customerSegments } from './customerSegments.json'; + +const MyComponent = () => { + return ( + + {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'; +import { OrderedList, ListItem } from 'my-favorite-ui-lib'; + +const DashboardMostVisitedPosts = () => ( + + + No posts found} + 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 { ListBase, ListIterator } from 'react-admin'; +import { OrderedList, ListItem } from 'my-favorite-ui-lib'; +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 { ListBase, ListIterator } from 'react-admin'; +import { OrderedList, ListItem, Skeleton } from 'my-favorite-ui-lib'; + +const DashboardMostVisitedPosts = () => ( + + + } + 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 don't leverage the [`RecordContext`](./useRecordContext.md): + +{% raw %} +```tsx +import { ListBase, ListIterator } from 'react-admin'; +import { OrderedList, ListItem } from 'my-favorite-ui-lib'; + +const DashboardMostVisitedPosts = () => ( + + + {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 { OrderedList, ListItem } from 'my-favorite-ui-lib'; +import { customerSegments } from './customerSegments.json'; + +const MyComponent = () => { + return ( + + {record.name}} + /> + + ); +} +``` +{% endraw %} + diff --git a/packages/ra-core/src/controller/list/ListIterator.spec.tsx b/packages/ra-core/src/controller/list/ListIterator.spec.tsx new file mode 100644 index 00000000000..3ed2b532627 --- /dev/null +++ b/packages/ra-core/src/controller/list/ListIterator.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { UsingChildren, UsingRender } from './ListIterator.stories'; + +describe('', () => { + describe.each([ + { Story: UsingRender, prop: 'render' }, + { Story: UsingChildren, prop: 'children' }, + ])('Using the $prop prop', ({ Story }) => { + it('should render the records', async () => { + render(); + + await screen.findByText('War and Peace'); + await screen.findByText('The Lion, the Witch and the Wardrobe'); + }); + it('should render the pending prop when ListContext.isPending is true', async () => { + render(); + + await screen.findByText('Loading...'); + }); + it('should render the empty prop when there is no data', async () => { + render(); + + await screen.findByText('No data'); + }); + }); +}); diff --git a/packages/ra-core/src/controller/list/ListIterator.stories.tsx b/packages/ra-core/src/controller/list/ListIterator.stories.tsx new file mode 100644 index 00000000000..ae1fcbc44f9 --- /dev/null +++ b/packages/ra-core/src/controller/list/ListIterator.stories.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { useList, UseListOptions } from './useList'; +import { ListContextProvider } from './ListContextProvider'; +import { ListIterator } from './ListIterator'; +import { useRecordContext } from '../record'; + +export default { + title: 'ra-core/controller/list/ListIterator', +}; + +const data = [ + { id: 1, title: 'War and Peace' }, + { id: 2, title: 'The Little Prince' }, + { id: 3, title: "Swann's Way" }, + { id: 4, title: 'A Tale of Two Cities' }, + { id: 5, title: 'The Lord of the Rings' }, + { id: 6, title: 'And Then There Were None' }, + { id: 7, title: 'Dream of the Red Chamber' }, + { id: 8, title: 'The Hobbit' }, + { id: 9, title: 'She: A History of Adventure' }, + { id: 10, title: 'The Lion, the Witch and the Wardrobe' }, +]; + +export const UsingRender = ({ + empty, + ...props +}: UseListOptions & { empty?: boolean }) => { + const value = useList({ + data: empty ? [] : data, + sort: { field: 'id', order: 'ASC' }, + ...props, + }); + + return ( + +
    + Loading...} + empty={
    No data
    } + render={record => ( +
  • + {record.title} +
  • + )} + /> +
+
+ ); +}; + +UsingRender.args = { + isPending: false, + empty: false, +}; + +UsingRender.argTypes = { + isPending: { control: 'boolean' }, + empty: { control: 'boolean' }, +}; + +const ListItem = () => { + const record = useRecordContext(); + return ( +
  • + {record?.title} +
  • + ); +}; + +export const UsingChildren = ({ + empty, + ...props +}: UseListOptions & { empty?: boolean }) => { + const value = useList({ + data: empty ? [] : data, + sort: { field: 'id', order: 'ASC' }, + ...props, + }); + + return ( + +
      + Loading...} + empty={
      No data
      } + > + +
      +
    +
    + ); +}; + +UsingChildren.args = { + isPending: false, + empty: false, +}; + +UsingChildren.argTypes = { + isPending: { control: 'boolean' }, + empty: { control: 'boolean' }, +}; diff --git a/packages/ra-core/src/controller/list/ListIterator.tsx b/packages/ra-core/src/controller/list/ListIterator.tsx new file mode 100644 index 00000000000..d4f36ad4f4c --- /dev/null +++ b/packages/ra-core/src/controller/list/ListIterator.tsx @@ -0,0 +1,64 @@ +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 = null, loading = null, render } = props; + const { data, total, isPending } = + useListContextWithProps(props); + + if (isPending === true) { + return loading ? loading : 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' + ); + } + + if (render) { + return ( + <> + {data.map((record, index) => ( + + {render(record, index)} + + ))} + + ); + } + + return ( + <> + {data.map((record, index) => ( + + {children} + + ))} + + ); +}; + +export interface ListIteratorProps { + children?: React.ReactNode; + empty?: React.ReactElement; + loading?: React.ReactElement; + render?: (record: RecordType, index: number) => React.ReactNode; + data?: RecordType[]; + total?: number; + isPending?: boolean; +} diff --git a/packages/ra-core/src/controller/list/index.ts b/packages/ra-core/src/controller/list/index.ts index 1953cb4ad07..a3c955def3a 100644 --- a/packages/ra-core/src/controller/list/index.ts +++ b/packages/ra-core/src/controller/list/index.ts @@ -5,6 +5,7 @@ export * from './ListContext'; export * from './ListContextProvider'; export * from './ListController'; export * from './ListFilterContext'; +export * from './ListIterator'; export * from './ListPaginationContext'; export * from './ListSortContext'; export * from './queryReducer'; diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index afb4c1d69d8..a74914d920e 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -13,8 +13,8 @@ import { useThemeProps, } from '@mui/material/styles'; import { + ListIterator, type RaRecord, - RecordContextProvider, sanitizeListRestProps, useGetRecordRepresentation, useListContextWithProps, @@ -122,8 +122,10 @@ export const SimpleList = ( return ( - {data.map((record, rowIndex) => ( - + + data={data} + total={total} + render={(record, rowIndex) => ( ( rowIndex={rowIndex} /> - - ))} + )} + /> ); }; diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index b3398126a1a..b527fedceee 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -12,9 +12,9 @@ import { useListContextWithProps, useResourceContext, type RaRecord, - RecordContextProvider, RecordRepresentation, useCreatePath, + ListIterator, } from 'ra-core'; import { LinearProgress } from '../layout/LinearProgress'; @@ -54,7 +54,9 @@ import { Link } from '../Link'; * *
    */ -export const SingleFieldList = (inProps: SingleFieldListProps) => { +export const SingleFieldList = ( + inProps: SingleFieldListProps +) => { const props = useThemeProps({ props: inProps, name: PREFIX, @@ -87,21 +89,21 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { className={className} {...sanitizeListRestProps(rest)} > - {data.map((record, rowIndex) => { - const resourceLinkPath = !linkType - ? false - : createPath({ - resource, - type: linkType, - id: record.id, - }); - - if (resourceLinkPath) { - return ( - + + data={data} + total={total} + isPending={isPending} + render={record => { + const resourceLinkPath = !linkType + ? false + : createPath({ + resource, + type: linkType, + id: record.id, + }); + + if (resourceLinkPath) { + return ( { )} - - ); - } - - return ( - - {children || } - - ); - })} + ); + } + + return children || ; + }} + /> ); };