Skip to content

Commit 1bd04dd

Browse files
authored
Merge pull request #10830 from marmelab/list-iterator
Introduce `<ListIterator>`
2 parents ccf10db + d94631a commit 1bd04dd

File tree

8 files changed

+475
-39
lines changed

8 files changed

+475
-39
lines changed

.lintstagedrc

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
{
22
"*.{js,jsx,ts,tsx}": [
3-
"eslint --fix",
4-
"git add",
3+
"eslint --fix"
54
],
65
"*.{json,css,md}": [
7-
"prettier",
8-
"git add"
6+
"prettier"
97
]
108
}

docs/ListIterator.md

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
---
2+
layout: default
3+
title: "ListIterator"
4+
storybook_path: ra-core-controller-list-listiterator--using-render
5+
---
6+
7+
# `<ListIterator>`
8+
9+
## Usage
10+
11+
Use the `<ListIterator>` component as a child of any component that provides a [`ListContext`](./useListContext.md):
12+
13+
- `<List>`,
14+
- `<ListGuesser>`,
15+
- `<ListBase>`,
16+
- `<ReferenceArrayField>`,
17+
- `<ReferenceManyField>`
18+
19+
{% raw %}
20+
```jsx
21+
import { ListBase, ListIterator } from 'react-admin';
22+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
23+
24+
const DashboardMostVisitedPosts = () => (
25+
<ListBase resource="posts" sort={{ field: 'views', order: 'DESC' }} page={1} perPage={20}>
26+
<OrderedList>
27+
<ListIterator
28+
render={record => <ListItem>{record.title} - {record.views}</ListItem>}
29+
/>
30+
</OrderedList>
31+
</ListBase>
32+
);
33+
```
34+
{% endraw %}
35+
36+
## Props
37+
38+
Here are all the props you can set on the `<AccordionForm>` component:
39+
40+
| Prop | Required | Type | Default | Description |
41+
| ----------- | -------- | ---------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- |
42+
| `children` | Optional | `ReactNode` | - | The content to render for each record |
43+
| `data` | Optional | `RaRecord[]` | - | The records. Defaults to the `data` from the `ListContext` |
44+
| `empty` | Optional | `ReactNode` | `null` | The content to display when there is no data |
45+
| `isPending` | Optional | `boolean` | - | A boolean indicating whether the data is pending. Defaults to the `isPending` from the `ListContext` |
46+
| `loading` | Optional | `ReactNode` | `null` | The content to display while the data is loading |
47+
| `render` | Optional | `(record: RaRecord) => ReactNode` | - | A function that returns the content to render for each record |
48+
| `total` | Optional | `number` | - | The total number of records. Defaults to the `total` from the `ListContext` |
49+
50+
Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform).
51+
52+
## `children`
53+
54+
If provided, `ListIterator` will render the `children` prop for each record. This is useful when the components you render leverages the [`RecordContext`](./useRecordContext.md):
55+
56+
{% raw %}
57+
```tsx
58+
import { ListBase, ListIterator, useRecordContext } from 'react-admin';
59+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
60+
61+
const DashboardMostVisitedPosts = () => (
62+
<ListBase resource="posts" sort={{ field: 'views', order: 'DESC' }} page={1} perPage={20}>
63+
<OrderedList>
64+
<ListIterator>
65+
<PostItem />
66+
</ListIterator>
67+
</OrderedList>
68+
</ListBase>
69+
);
70+
71+
const PostItem = () => {
72+
const record = useRecordContext();
73+
if (!record) return null;
74+
75+
return (
76+
<ListItem>{record.title} - {record.views}</ListItem>
77+
);
78+
};
79+
```
80+
{% endraw %}
81+
82+
**Note**: You can't provide both the `children` and the `render` props. If both are provided, `<ListIterator>` will use the `render` prop.
83+
84+
## `data`
85+
86+
Although `<ListIterator>` reads the data from the closest [`<ListContext>`](./useListContext), you may provide it yourself when no such context is available:
87+
88+
{% raw %}
89+
```jsx
90+
import { ListIterator } from 'react-admin';
91+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
92+
import { customerSegments } from './customerSegments.json';
93+
94+
const MyComponent = () => {
95+
return (
96+
<OrderedList>
97+
<ListIterator
98+
data={customerSegments}
99+
total={customerSegments.length}
100+
render={record => <ListItem>{record.name}</ListItem>}
101+
/>
102+
</OrderedList>
103+
);
104+
}
105+
```
106+
{% endraw %}
107+
108+
## `empty`
109+
110+
To provide a custom UI when there is no data, use the `empty` prop.
111+
112+
{% raw %}
113+
```jsx
114+
import { ListBase, ListIterator } from 'react-admin';
115+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
116+
117+
const DashboardMostVisitedPosts = () => (
118+
<ListBase resource="posts" sort={{ field: 'views', order: 'DESC' }} page={1} perPage={20}>
119+
<OrderedList>
120+
<ListIterator
121+
empty={<ListItem>No posts found</ListItem>}
122+
render={record => <ListItem>{record.title} - {record.views}</ListItem>}
123+
/>
124+
</OrderedList>
125+
</ListBase>
126+
);
127+
```
128+
{% endraw %}
129+
130+
## `isPending`
131+
132+
Although `<ListIterator>` reads the `isPending` from the closest [`<ListContext>`](./useListContext), you may provide it yourself when no such context is available. This is useful when dealing with data not coming from the `dataProvider`:
133+
134+
{% raw %}
135+
```tsx
136+
import { ListBase, ListIterator } from 'react-admin';
137+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
138+
import { useQuery } from '@tanstack/react-query';
139+
import { fetchPostAnalytics } from './fetchPostAnalytics';
140+
141+
const DashboardMostVisitedPosts = () => {
142+
const { data, isPending } = useQuery({
143+
queryKey: ['dashboard', 'posts'],
144+
queryFn: fetchPostAnalytics
145+
});
146+
147+
return (
148+
<OrderedList>
149+
<ListIterator
150+
data={data}
151+
isPending={isPending}
152+
render={record => <ListItem>{record.title} - {record.views}</ListItem>}
153+
/>
154+
</OrderedList>
155+
);
156+
}
157+
```
158+
{% endraw %}
159+
160+
161+
## `loading`
162+
163+
To provide a custom UI while the data is loading use the `loading` prop.
164+
165+
{% raw %}
166+
```jsx
167+
import { ListBase, ListIterator } from 'react-admin';
168+
import { OrderedList, ListItem, Skeleton } from 'my-favorite-ui-lib';
169+
170+
const DashboardMostVisitedPosts = () => (
171+
<ListBase resource="posts" sort={{ field: 'views', order: 'DESC' }} page={1} perPage={20}>
172+
<OrderedList>
173+
<ListIterator
174+
loading={<Skeleton />}
175+
render={record => <ListItem>{record.title} - {record.views}</ListItem>}
176+
/>
177+
</OrderedList>
178+
</ListBase>
179+
);
180+
```
181+
{% endraw %}
182+
183+
## `render`
184+
185+
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):
186+
187+
{% raw %}
188+
```tsx
189+
import { ListBase, ListIterator } from 'react-admin';
190+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
191+
192+
const DashboardMostVisitedPosts = () => (
193+
<ListBase resource="posts" sort={{ field: 'views', order: 'DESC' }} page={1} perPage={20}>
194+
<OrderedList>
195+
<ListIterator
196+
render={record => <ListItem>{record.title} - {record.views}</ListItem>}
197+
/>
198+
</OrderedList>
199+
</ListBase>
200+
);
201+
```
202+
{% endraw %}
203+
204+
**Note**: You can't provide both the `children` and the `render` props. If both are provided, `<ListIterator>` will use the `render` prop.
205+
206+
## `total`
207+
208+
Although `<ListIterator>` reads the total from the closest [`<ListContext>`](./useListContext), you may provide it yourself when no such context is available:
209+
210+
{% raw %}
211+
```jsx
212+
import { ListIterator } from 'react-admin';
213+
import { OrderedList, ListItem } from 'my-favorite-ui-lib';
214+
import { customerSegments } from './customerSegments.json';
215+
216+
const MyComponent = () => {
217+
return (
218+
<OrderedList>
219+
<ListIterator
220+
data={customerSegments}
221+
total={customerSegments.length}
222+
render={record => <ListItem>{record.name}</ListItem>}
223+
/>
224+
</OrderedList>
225+
);
226+
}
227+
```
228+
{% endraw %}
229+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { UsingChildren, UsingRender } from './ListIterator.stories';
4+
5+
describe('<ListIterator>', () => {
6+
describe.each([
7+
{ Story: UsingRender, prop: 'render' },
8+
{ Story: UsingChildren, prop: 'children' },
9+
])('Using the $prop prop', ({ Story }) => {
10+
it('should render the records', async () => {
11+
render(<Story />);
12+
13+
await screen.findByText('War and Peace');
14+
await screen.findByText('The Lion, the Witch and the Wardrobe');
15+
});
16+
it('should render the pending prop when ListContext.isPending is true', async () => {
17+
render(<Story isPending />);
18+
19+
await screen.findByText('Loading...');
20+
});
21+
it('should render the empty prop when there is no data', async () => {
22+
render(<Story empty />);
23+
24+
await screen.findByText('No data');
25+
});
26+
});
27+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React from 'react';
2+
import { useList, UseListOptions } from './useList';
3+
import { ListContextProvider } from './ListContextProvider';
4+
import { ListIterator } from './ListIterator';
5+
import { useRecordContext } from '../record';
6+
7+
export default {
8+
title: 'ra-core/controller/list/ListIterator',
9+
};
10+
11+
const data = [
12+
{ id: 1, title: 'War and Peace' },
13+
{ id: 2, title: 'The Little Prince' },
14+
{ id: 3, title: "Swann's Way" },
15+
{ id: 4, title: 'A Tale of Two Cities' },
16+
{ id: 5, title: 'The Lord of the Rings' },
17+
{ id: 6, title: 'And Then There Were None' },
18+
{ id: 7, title: 'Dream of the Red Chamber' },
19+
{ id: 8, title: 'The Hobbit' },
20+
{ id: 9, title: 'She: A History of Adventure' },
21+
{ id: 10, title: 'The Lion, the Witch and the Wardrobe' },
22+
];
23+
24+
export const UsingRender = ({
25+
empty,
26+
...props
27+
}: UseListOptions & { empty?: boolean }) => {
28+
const value = useList({
29+
data: empty ? [] : data,
30+
sort: { field: 'id', order: 'ASC' },
31+
...props,
32+
});
33+
34+
return (
35+
<ListContextProvider value={value}>
36+
<ul
37+
style={{
38+
listStyleType: 'none',
39+
}}
40+
>
41+
<ListIterator
42+
loading={<div>Loading...</div>}
43+
empty={<div>No data</div>}
44+
render={record => (
45+
<li
46+
style={{
47+
padding: '10px',
48+
border: '1px solid #ccc',
49+
}}
50+
>
51+
{record.title}
52+
</li>
53+
)}
54+
/>
55+
</ul>
56+
</ListContextProvider>
57+
);
58+
};
59+
60+
UsingRender.args = {
61+
isPending: false,
62+
empty: false,
63+
};
64+
65+
UsingRender.argTypes = {
66+
isPending: { control: 'boolean' },
67+
empty: { control: 'boolean' },
68+
};
69+
70+
const ListItem = () => {
71+
const record = useRecordContext();
72+
return (
73+
<li
74+
style={{
75+
padding: '10px',
76+
border: '1px solid #ccc',
77+
}}
78+
>
79+
{record?.title}
80+
</li>
81+
);
82+
};
83+
84+
export const UsingChildren = ({
85+
empty,
86+
...props
87+
}: UseListOptions & { empty?: boolean }) => {
88+
const value = useList({
89+
data: empty ? [] : data,
90+
sort: { field: 'id', order: 'ASC' },
91+
...props,
92+
});
93+
94+
return (
95+
<ListContextProvider value={value}>
96+
<ul
97+
style={{
98+
listStyleType: 'none',
99+
}}
100+
>
101+
<ListIterator
102+
loading={<div>Loading...</div>}
103+
empty={<div>No data</div>}
104+
>
105+
<ListItem />
106+
</ListIterator>
107+
</ul>
108+
</ListContextProvider>
109+
);
110+
};
111+
112+
UsingChildren.args = {
113+
isPending: false,
114+
empty: false,
115+
};
116+
117+
UsingChildren.argTypes = {
118+
isPending: { control: 'boolean' },
119+
empty: { control: 'boolean' },
120+
};

0 commit comments

Comments
 (0)