Skip to content

Commit bad689e

Browse files
authored
Merge pull request #10896 from marmelab/offline-support-list
Add offline support to `<ListBase>` and `<List>`
2 parents 34804e1 + 2d96182 commit bad689e

20 files changed

+693
-168
lines changed

docs/InfiniteList.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ The props are the same as [the `<List>` component](./List.md):
7272
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
7373
| `filter` | Optional | `object` | - | The permanent filter values. |
7474
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
75+
| `offline` | Optional | `ReactNode` | `<Offline>` | The component to render when there is no connectivity and there is no data in the cache |
7576
| `pagination` | Optional | `ReactElement` | `<Infinite Pagination>` | The pagination component to use. |
7677
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
7778
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
@@ -85,6 +86,49 @@ Check the [`<List>` component](./List.md) for details about each prop.
8586

8687
Additional props are passed down to the root component (a MUI `<Card>` by default).
8788

89+
## `offline`
90+
91+
By default, `<InfiniteList>` renders the `<Offline>` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:
92+
93+
```jsx
94+
import { InfiniteList, InfinitePagination } from 'react-admin';
95+
import { Alert } from '@mui/material';
96+
97+
const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;
98+
// The offline component may be displayed at the bottom of the page if the network connectivity is lost
99+
// when loading new pages. Make sure you pass your custom offline component here too
100+
const pagination = <InfinitePagination offline={offline} />;
101+
102+
export const PostList = () => (
103+
<InfiniteList offline={offline} pagination={pagination}>
104+
...
105+
</InfiniteList>
106+
);
107+
```
108+
109+
**Tip**: If the record is in the Tanstack Query cache but you want to warn the user that they may see an outdated version, you can use the `<IsOffline>` component:
110+
111+
```jsx
112+
import { InfiniteList, InfinitePagination, IsOffline } from 'react-admin';
113+
import { Alert } from '@mui/material';
114+
115+
const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;
116+
// The offline component may be displayed at the bottom of the page if the network connectivity is lost
117+
// when loading new pages. Make sure you pass your custom offline component here too
118+
const pagination = <InfinitePagination offline={offline} />;
119+
120+
export const PostList = () => (
121+
<InfiniteList offline={offline} pagination={pagination}>
122+
<IsOffline>
123+
<Alert severity="warning">
124+
You are offline, the data may be outdated
125+
</Alert>
126+
</IsOffline>
127+
...
128+
</InfiniteList>
129+
);
130+
```
131+
88132
## `pagination`
89133

90134
You can replace the default "load on scroll" pagination (triggered by a component named `<InfinitePagination>`) by a custom pagination component. To get the pagination state and callbacks, you'll need to read the `InfinitePaginationContext`.

docs/List.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ You can find more advanced examples of `<List>` usage in the [demos](./Demos.md)
6969
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
7070
| `filter` | Optional | `object` | - | The permanent filter values. |
7171
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
72+
| `offline` | Optional | `ReactNode` | `<Offline>` | The component to render when there is no connectivity and there is no data in the cache |
7273
| `pagination` | Optional | `ReactElement` | `<Pagination>` | The pagination component to use. |
7374
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
7475
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
@@ -774,6 +775,43 @@ export const PostList = () => (
774775
const filterSentToDataProvider = { ...filterDefaultValues, ...filterChosenByUser, ...filter };
775776
```
776777

778+
## `offline`
779+
780+
By default, `<List>` renders the `<Offline>` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:
781+
782+
```jsx
783+
import { List } from 'react-admin';
784+
import { Alert } from '@mui/material';
785+
786+
const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;
787+
788+
export const PostList = () => (
789+
<List offline={offline}>
790+
...
791+
</List>
792+
);
793+
```
794+
795+
**Tip**: If the record is in the Tanstack Query cache but you want to warn the user that they may see an outdated version, you can use the `<IsOffline>` component:
796+
797+
```jsx
798+
import { List, IsOffline } from 'react-admin';
799+
import { Alert } from '@mui/material';
800+
801+
const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;
802+
803+
export const PostList = () => (
804+
<List offline={offline}>
805+
<IsOffline>
806+
<Alert severity="warning">
807+
You are offline, the data may be outdated
808+
</Alert>
809+
</IsOffline>
810+
...
811+
</List>
812+
);
813+
```
814+
777815
## `pagination`
778816

779817
By default, the `<List>` view displays a set of pagination controls at the bottom of the list.

docs/ListBase.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ The `<ListBase>` component accepts the following props:
8787
* [`exporter`](./List.md#exporter)
8888
* [`filter`](./List.md#filter-permanent-filter)
8989
* [`filterDefaultValues`](./List.md#filterdefaultvalues)
90+
* [`offline`](./List.md#offline)
9091
* [`perPage`](./List.md#perpage)
9192
* [`queryOptions`](./List.md#queryoptions)
9293
* `render`

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ describe('InfiniteListBase', () => {
7373
resolveAuth!();
7474
await screen.findByText('Hello');
7575
});
76+
it('should not wait for the authentication resolution before loading data when disableAuthentication is true', async () => {
77+
const authProvider = {
78+
login: () => Promise.resolve(),
79+
logout: () => Promise.resolve(),
80+
checkError: () => Promise.resolve(),
81+
checkAuth: jest.fn(),
82+
};
83+
const dataProvider = testDataProvider({
84+
// @ts-ignore
85+
getList: jest.fn(() =>
86+
Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 })
87+
),
88+
});
89+
render(
90+
<WithAuthProviderNoAccessControl
91+
authProvider={authProvider}
92+
dataProvider={dataProvider}
93+
InfiniteListProps={{ disableAuthentication: true }}
94+
/>
95+
);
96+
await screen.findByText('Hello');
97+
expect(authProvider.checkAuth).not.toHaveBeenCalled();
98+
});
7699
it('should wait for both the authentication and authorization resolution before loading data', async () => {
77100
let resolveAuth: () => void;
78101
let resolveCanAccess: (value: boolean) => void;

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

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ import fakeRestProvider from 'ra-data-fakerest';
33
import englishMessages from 'ra-language-english';
44
import frenchMessages from 'ra-language-french';
55
import polyglotI18nProvider from 'ra-i18n-polyglot';
6-
import { InfiniteListBase } from './InfiniteListBase';
6+
import { InfiniteListBase, InfiniteListBaseProps } from './InfiniteListBase';
77
import { CoreAdminContext } from '../../core';
88
import { useListContext } from './useListContext';
99
import { useInfinitePaginationContext } from './useInfinitePaginationContext';
1010
import {
1111
AuthProvider,
1212
DataProvider,
1313
I18nProvider,
14+
IsOffline,
1415
mergeTranslations,
16+
TestMemoryRouter,
1517
useLocaleState,
1618
} from '../..';
19+
import { onlineManager } from '@tanstack/react-query';
1720

1821
export default {
1922
title: 'ra-core/controller/list/InfiniteListBase',
@@ -140,15 +143,18 @@ export const WithAuthProviderNoAccessControl = ({
140143
checkError: () => Promise.resolve(),
141144
},
142145
dataProvider = defaultDataProvider,
146+
InfiniteListProps,
143147
}: {
144148
authProvider?: AuthProvider;
145149
dataProvider?: DataProvider;
150+
InfiniteListProps?: Partial<InfiniteListBaseProps>;
146151
}) => (
147152
<CoreAdminContext authProvider={authProvider} dataProvider={dataProvider}>
148153
<InfiniteListBase
149154
resource="books"
150155
perPage={5}
151156
loading={<div>Authentication loading...</div>}
157+
{...InfiniteListProps}
152158
>
153159
<BookListView />
154160
</InfiniteListBase>
@@ -314,6 +320,93 @@ export const WithRenderProps = () => (
314320
</CoreAdminContext>
315321
);
316322

323+
export const Offline = ({
324+
dataProvider = defaultDataProvider,
325+
isOnline = true,
326+
...props
327+
}: {
328+
dataProvider?: DataProvider;
329+
isOnline?: boolean;
330+
} & Partial<InfiniteListBaseProps>) => {
331+
React.useEffect(() => {
332+
onlineManager.setOnline(isOnline);
333+
}, [isOnline]);
334+
return (
335+
<TestMemoryRouter>
336+
<CoreAdminContext dataProvider={dataProvider}>
337+
<InfiniteListBase
338+
resource="books"
339+
perPage={5}
340+
{...props}
341+
offline={<p>You are offline, cannot load data</p>}
342+
render={controllerProps => {
343+
const {
344+
data,
345+
error,
346+
isPending,
347+
page,
348+
perPage,
349+
setPage,
350+
total,
351+
} = controllerProps;
352+
if (isPending) {
353+
return <div>Loading...</div>;
354+
}
355+
if (error) {
356+
return <div>Error...</div>;
357+
}
358+
359+
return (
360+
<div>
361+
<p>
362+
Use the story controls to simulate offline
363+
mode:
364+
</p>
365+
<IsOffline>
366+
<p style={{ color: 'orange' }}>
367+
You are offline, the data may be
368+
outdated
369+
</p>
370+
</IsOffline>
371+
<button
372+
disabled={page <= 1}
373+
onClick={() => setPage(page - 1)}
374+
>
375+
previous
376+
</button>
377+
<span>
378+
Page {page} of {Math.ceil(total / perPage)}
379+
</span>
380+
<button
381+
disabled={page >= total / perPage}
382+
onClick={() => setPage(page + 1)}
383+
>
384+
next
385+
</button>
386+
<ul>
387+
{data.map((record: any) => (
388+
<li key={record.id}>{record.title}</li>
389+
))}
390+
</ul>
391+
</div>
392+
);
393+
}}
394+
/>
395+
</CoreAdminContext>
396+
</TestMemoryRouter>
397+
);
398+
};
399+
400+
Offline.args = {
401+
isOnline: true,
402+
};
403+
404+
Offline.argTypes = {
405+
isOnline: {
406+
control: { type: 'boolean' },
407+
},
408+
};
409+
317410
const Title = () => {
318411
const { defaultTitle } = useListContext();
319412
const [locale, setLocale] = useLocaleState();

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ import { useIsAuthPending } from '../../auth';
4848
export const InfiniteListBase = <RecordType extends RaRecord = any>({
4949
children,
5050
render,
51-
loading = null,
51+
loading,
52+
offline,
5253
...props
5354
}: InfiniteListBaseProps<RecordType>) => {
5455
const controllerProps = useInfiniteListController<RecordType>(props);
@@ -57,16 +58,27 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
5758
action: 'list',
5859
});
5960

60-
if (isAuthPending && !props.disableAuthentication) {
61-
return loading;
62-
}
63-
6461
if (!render && !children) {
6562
throw new Error(
6663
"<InfiniteListBase> requires either a 'render' prop or 'children' prop"
6764
);
6865
}
6966

67+
const showLoading =
68+
isAuthPending &&
69+
!props.disableAuthentication &&
70+
loading !== undefined &&
71+
loading !== false;
72+
73+
const { isPaused, isPending, isPlaceholderData } = controllerProps;
74+
const showOffline =
75+
isPaused &&
76+
// If isPending and isPaused are true, we are offline and couldn't even load the initial data
77+
// If isPaused and isPlaceholderData are true, we are offline and couldn't even load data with different parameters on the same useQuery observer
78+
(isPending || isPlaceholderData) &&
79+
offline !== undefined &&
80+
offline !== false;
81+
7082
return (
7183
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
7284
<OptionalResourceContextProvider value={props.resource}>
@@ -82,7 +94,13 @@ export const InfiniteListBase = <RecordType extends RaRecord = any>({
8294
controllerProps.isFetchingPreviousPage,
8395
}}
8496
>
85-
{render ? render(controllerProps) : children}
97+
{showLoading
98+
? loading
99+
: showOffline
100+
? offline
101+
: render
102+
? render(controllerProps)
103+
: children}
86104
</InfinitePaginationContext.Provider>
87105
</ListContextProvider>
88106
</OptionalResourceContextProvider>
@@ -93,5 +111,6 @@ export interface InfiniteListBaseProps<RecordType extends RaRecord = any>
93111
extends InfiniteListControllerProps<RecordType> {
94112
loading?: ReactNode;
95113
children?: ReactNode;
114+
offline?: ReactNode;
96115
render?: (props: InfiniteListControllerResult<RecordType>) => ReactNode;
97116
}

0 commit comments

Comments
 (0)