Skip to content

Commit cf71dae

Browse files
authored
Merge pull request #10940 from marmelab/empty-list-base
Add empty property to ListBase and InfiniteListBase
2 parents fdaa4d8 + 224c44f commit cf71dae

File tree

11 files changed

+238
-10
lines changed

11 files changed

+238
-10
lines changed

docs/ListBase.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ The `<ListBase>` component accepts the following props:
8585
* [`debounce`](./List.md#debounce)
8686
* [`disableAuthentication`](./List.md#disableauthentication)
8787
* [`disableSyncWithLocation`](./List.md#disablesyncwithlocation)
88+
* [`empty`](./List.md#empty)
8889
* [`emptyWhileLoading`](./List.md#emptywhileloading)
8990
* [`error`](./List.md#error)
9091
* [`exporter`](./List.md#exporter)

docs_headless/src/content/docs/InfiniteListBase.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ That's enough to display a basic list with infinite scroll functionality. When u
106106
| `debounce` | Optional | `number` | `500` | The debounce delay in milliseconds to apply when users change the sort or filter parameters. |
107107
| `disable Authentication` | Optional | `boolean` | `false` | Set to `true` to disable the authentication check. |
108108
| `disable SyncWithLocation` | Optional | `boolean` | `false` | Set to `true` to disable the synchronization of the list parameters with the URL. |
109+
| `empty` | Optional | `ReactNode` | - | The component to display when the list is empty. |
110+
| `emptyWhileLoading` | Optional | `boolean` | - | Set to `true` to return `null` while the list is loading. |
109111
| `error` | Optional | `ReactNode` | - | The component to render when failing to load the list of records. |
110112
| `exporter` | Optional | `function` | - | The function to call to export the list. |
111113
| `filter` | Optional | `object` | - | The permanent filter values. |

docs_headless/src/content/docs/ListBase.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ The `<ListBase>` component accepts the following props:
8686
| `debounce` | Optional | `number` | `500` | The debounce delay in milliseconds to apply when users change the sort or filter parameters. |
8787
| `disableAuthentication` | Optional | `boolean` | `false` | Set to `true` to disable the authentication check. |
8888
| `disableSyncWithLocation` | Optional | `boolean` | `false` | Set to `true` to disable the synchronization of the list parameters with the URL. |
89+
| `empty` | Optional | `ReactNode` | - | The component to display when the list is empty. |
90+
| `emptyWhileLoading` | Optional | `boolean` | - | Set to `true` to return `null` while the list is loading. |
8991
| `error` | Optional | `ReactNode` | - | The component to render when failing to load the list of records. |
9092
| `exporter` | Optional | `function` | - | The function to call to export the list. |
9193
| `filter` | Optional | `object` | - | The permanent filter values. |
@@ -210,6 +212,90 @@ const Dashboard = () => (
210212
);
211213
```
212214
215+
## `empty`
216+
217+
By default, `<ListBase>` renders the children when there is no result and no active filter. If you want for instance to invite users to create the first record, you can render a custom component via the `empty` prop:
218+
219+
```jsx
220+
import { ListBase } from 'ra-core';
221+
import { Link } from 'react-router';
222+
223+
const Empty = () => (
224+
<div>
225+
<h4>
226+
No product available
227+
</h4>
228+
<Link to="/products/create">Create the first product</Link>
229+
</div>
230+
);
231+
232+
const ProductList = () => (
233+
<ListBase empty={<Empty />}>
234+
...
235+
</ListBase>
236+
);
237+
```
238+
239+
The `empty` component can call the [`useListContext()`](./useListContext.md) hook to receive the same props as the `ListBase` child component.
240+
241+
## `emptyWhileLoading`
242+
243+
Many list view components return null when the data is loading. If you use a custom view component as the `<ListBase>` children instead, you'll have to handle the case where the `data` is not yet defined.
244+
245+
That means that the following will fail on load with a "ReferenceError: data is not defined" error:
246+
247+
```jsx
248+
import { ListBase, useListContext } from 'ra-core';
249+
250+
const SimpleBookList = () => {
251+
const { data } = useListContext();
252+
return (
253+
<ul>
254+
{data.map(book => (
255+
<li key={book.id}>
256+
<i>{book.title}</i>, by {book.author} ({book.year})
257+
</li>
258+
))}
259+
</ul>
260+
);
261+
}
262+
263+
const BookList = () => (
264+
<ListBase>
265+
<SimpleBookList />
266+
</ListBase>
267+
);
268+
```
269+
270+
You can handle this case by getting the `isPending` variable from the [`useListContext`](./useListContext.md) hook:
271+
272+
```jsx
273+
const SimpleBookList = () => {
274+
const { data, isPending } = useListContext();
275+
if (isPending) return null;
276+
return (
277+
<ul>
278+
{data.map(book => (
279+
<li key={book.id}>
280+
<i>{book.title}</i>, by {book.author} ({book.year})
281+
</li>
282+
))}
283+
</ul>
284+
);
285+
}
286+
```
287+
288+
The `<ListBase emptyWhileLoading>` prop provides a convenient shortcut for that use case. When enabled, `<ListBase>` won't render its child until `data` is defined.
289+
290+
```diff
291+
const BookList = () => (
292+
- <ListBase>
293+
+ <ListBase emptyWhileLoading>
294+
<SimpleBookList />
295+
</ListBase>
296+
);
297+
```
298+
213299
## `error`
214300
215301
By default, `<ListBase>` renders the children when an error happens while loading the list of records. You can render an error component via the `error` prop:

packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AccessControl,
44
Basic,
55
DefaultTitle,
6+
Empty,
67
FetchError,
78
Loading,
89
NoAuthProvider,
@@ -160,6 +161,14 @@ describe('InfiniteListBase', () => {
160161
expect(screen.queryByText('Loading...')).toBeNull();
161162
});
162163

164+
it('should render a custom empty component when data is empty', async () => {
165+
render(<Empty />);
166+
expect(screen.queryByText('Loading...')).not.toBeNull();
167+
expect(screen.queryByText('War and Peace')).toBeNull();
168+
fireEvent.click(screen.getByText('Resolve books loading'));
169+
await screen.findByText('No books');
170+
});
171+
163172
it('should render loading component while loading', async () => {
164173
render(<Loading />);
165174
expect(screen.queryByText('Loading books...')).not.toBeNull();

packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,39 @@ export const FetchError = () => {
252252
);
253253
};
254254

255+
export const Empty = () => {
256+
let resolveGetList: (() => void) | null = null;
257+
const dataProvider = {
258+
...defaultDataProvider,
259+
getList: () => {
260+
return new Promise<GetListResult>(resolve => {
261+
resolveGetList = () => resolve({ data: [], total: 0 });
262+
});
263+
},
264+
};
265+
266+
return (
267+
<CoreAdminContext dataProvider={dataProvider}>
268+
<button
269+
onClick={() => {
270+
resolveGetList && resolveGetList();
271+
}}
272+
>
273+
Resolve books loading
274+
</button>
275+
<InfiniteListBase
276+
resource="books"
277+
perPage={5}
278+
loading={<p>Loading...</p>}
279+
empty={<p>No books</p>}
280+
>
281+
<BookListView />
282+
<InfinitePagination />
283+
</InfiniteListBase>
284+
</CoreAdminContext>
285+
);
286+
};
287+
255288
const defaultI18nProvider = polyglotI18nProvider(
256289
locale =>
257290
locale === 'fr'

packages/ra-core/src/controller/list/InfiniteListBase.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
5050
loading,
5151
offline,
5252
error,
53+
empty,
5354
children,
5455
render,
5556
...props
@@ -71,6 +72,11 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
7172
isPending,
7273
isPlaceholderData,
7374
error: errorState,
75+
data,
76+
total,
77+
hasPreviousPage,
78+
hasNextPage,
79+
filterValues,
7480
} = controllerProps;
7581

7682
const showAuthLoading =
@@ -95,6 +101,23 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
95101

96102
const showError = errorState && error !== false && error !== undefined;
97103

104+
const showEmpty =
105+
!errorState &&
106+
// the list is not loading data for the first time
107+
!isPending &&
108+
// the API returned no data (using either normal or partial pagination)
109+
(total === 0 ||
110+
(total == null &&
111+
hasPreviousPage === false &&
112+
hasNextPage === false &&
113+
// @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it
114+
data.length === 0)) &&
115+
// the user didn't set any filters
116+
!Object.keys(filterValues).length &&
117+
// there is an empty page component
118+
empty !== undefined &&
119+
empty !== false;
120+
98121
return (
99122
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
100123
<OptionalResourceContextProvider value={props.resource}>
@@ -118,9 +141,11 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
118141
? offline
119142
: showError
120143
? error
121-
: render
122-
? render(controllerProps)
123-
: children}
144+
: showEmpty
145+
? empty
146+
: render
147+
? render(controllerProps)
148+
: children}
124149
</InfinitePaginationContext.Provider>
125150
</ListContextProvider>
126151
</OptionalResourceContextProvider>
@@ -133,6 +158,7 @@ export interface InfiniteListBaseProps<RecordType extends RaRecord = any>
133158
loading?: ReactNode;
134159
offline?: ReactNode;
135160
error?: ReactNode;
161+
empty?: ReactNode;
136162
children?: ReactNode;
137163
render?: (props: InfiniteListControllerResult<RecordType>) => ReactNode;
138164
}

packages/ra-core/src/controller/list/ListBase.spec.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import {
44
AccessControl,
55
DefaultTitle,
6+
Empty,
67
EmptyWhileLoading,
78
EmptyWhileLoadingRender,
89
FetchError,
@@ -168,6 +169,13 @@ describe('ListBase', () => {
168169
await screen.findByText('War and Peace');
169170
await screen.findByText('You are offline, the data may be outdated');
170171
});
172+
it('should render a custom empty component when data is empty', async () => {
173+
render(<Empty />);
174+
expect(screen.queryByText('Loading...')).not.toBeNull();
175+
expect(screen.queryByText('War and Peace')).toBeNull();
176+
fireEvent.click(screen.getByText('Resolve books loading'));
177+
await screen.findByText('No books');
178+
});
171179
it('should render nothing while loading if emptyWhileLoading is set to true', async () => {
172180
render(<EmptyWhileLoading />);
173181
expect(screen.queryByText('Loading...')).toBeNull();

packages/ra-core/src/controller/list/ListBase.stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,39 @@ export const FetchError = () => {
503503
);
504504
};
505505

506+
export const Empty = () => {
507+
let resolveGetList: (() => void) | null = null;
508+
const baseProvider = defaultDataProvider(0);
509+
const dataProvider = {
510+
...baseProvider,
511+
getList: () => {
512+
return new Promise<GetListResult>(resolve => {
513+
resolveGetList = () => resolve({ data: [], total: 0 });
514+
});
515+
},
516+
};
517+
518+
return (
519+
<CoreAdminContext dataProvider={dataProvider}>
520+
<button
521+
onClick={() => {
522+
resolveGetList && resolveGetList();
523+
}}
524+
>
525+
Resolve books loading
526+
</button>
527+
<ListBase
528+
resource="books"
529+
perPage={5}
530+
loading={<p>Loading...</p>}
531+
empty={<p>No books</p>}
532+
>
533+
<BookListView />
534+
</ListBase>
535+
</CoreAdminContext>
536+
);
537+
};
538+
506539
export const EmptyWhileLoading = () => {
507540
let resolveGetList: (() => void) | null = null;
508541
const baseProvider = defaultDataProvider(0);

packages/ra-core/src/controller/list/ListBase.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const ListBase = <RecordType extends RaRecord = any>({
5151
loading,
5252
offline,
5353
error,
54+
empty,
5455
render,
5556
...props
5657
}: ListBaseProps<RecordType>) => {
@@ -71,6 +72,11 @@ export const ListBase = <RecordType extends RaRecord = any>({
7172
isPending,
7273
isPlaceholderData,
7374
error: errorState,
75+
data,
76+
total,
77+
hasPreviousPage,
78+
hasNextPage,
79+
filterValues,
7480
} = controllerProps;
7581

7682
const showAuthLoading =
@@ -95,7 +101,25 @@ export const ListBase = <RecordType extends RaRecord = any>({
95101

96102
const showError = errorState && error !== false && error !== undefined;
97103

98-
const showEmpty = isPending && !showOffline && emptyWhileLoading === true;
104+
const showEmptyWhileLoading =
105+
isPending && !showOffline && emptyWhileLoading === true;
106+
107+
const showEmpty =
108+
!errorState &&
109+
// the list is not loading data for the first time
110+
!isPending &&
111+
// the API returned no data (using either normal or partial pagination)
112+
(total === 0 ||
113+
(total == null &&
114+
hasPreviousPage === false &&
115+
hasNextPage === false &&
116+
// @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it
117+
data.length === 0)) &&
118+
// the user didn't set any filters
119+
!Object.keys(filterValues).length &&
120+
// there is an empty page component
121+
empty !== undefined &&
122+
empty !== false;
99123

100124
return (
101125
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
@@ -109,11 +133,13 @@ export const ListBase = <RecordType extends RaRecord = any>({
109133
? offline
110134
: showError
111135
? error
112-
: showEmpty
136+
: showEmptyWhileLoading
113137
? null
114-
: render
115-
? render(controllerProps)
116-
: children}
138+
: showEmpty
139+
? empty
140+
: render
141+
? render(controllerProps)
142+
: children}
117143
</ListContextProvider>
118144
</OptionalResourceContextProvider>
119145
);
@@ -126,6 +152,7 @@ export interface ListBaseProps<RecordType extends RaRecord = any>
126152
loading?: ReactNode;
127153
offline?: ReactNode;
128154
error?: ReactNode;
155+
empty?: ReactNode;
129156
children?: ReactNode;
130157
render?: (props: ListControllerResult<RecordType, Error>) => ReactNode;
131158
}

packages/ra-ui-materialui/src/list/InfiniteList.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ const defaultFilter = {};
117117
const defaultAuthLoading = <Loading />;
118118

119119
export interface InfiniteListProps<RecordType extends RaRecord = any>
120-
extends Omit<InfiniteListBaseProps<RecordType>, 'children' | 'render'>,
120+
extends Omit<
121+
InfiniteListBaseProps<RecordType>,
122+
'children' | 'render' | 'empty'
123+
>,
121124
ListViewProps {}
122125

123126
const PREFIX = 'RaInfiniteList';

0 commit comments

Comments
 (0)