Skip to content

Commit f73e54f

Browse files
committed
Add offline support to <ListBase> and <List>
1 parent 42c8f8b commit f73e54f

File tree

12 files changed

+360
-155
lines changed

12 files changed

+360
-155
lines changed

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/ListBase.spec.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AccessControl,
55
DefaultTitle,
66
NoAuthProvider,
7+
Offline,
78
WithAuthProviderNoAccessControl,
89
WithRenderProps,
910
} from './ListBase.stories';
@@ -116,4 +117,24 @@ describe('ListBase', () => {
116117
expect(dataProvider.getList).toHaveBeenCalled();
117118
await screen.findByText('Hello');
118119
});
120+
121+
it('should render the offline prop node when offline', async () => {
122+
const { rerender } = render(<Offline isOnline={false} />);
123+
await screen.findByText('You are offline, cannot load data');
124+
rerender(<Offline isOnline={true} />);
125+
await screen.findByText('War and Peace');
126+
expect(
127+
screen.queryByText('You are offline, cannot load data')
128+
).toBeNull();
129+
rerender(<Offline isOnline={false} />);
130+
await screen.findByText('You are offline, the data may be outdated');
131+
fireEvent.click(screen.getByText('next'));
132+
await screen.findByText('You are offline, cannot load data');
133+
rerender(<Offline isOnline={true} />);
134+
await screen.findByText('And Then There Were None');
135+
rerender(<Offline isOnline={false} />);
136+
fireEvent.click(screen.getByText('previous'));
137+
await screen.findByText('War and Peace');
138+
await screen.findByText('You are offline, the data may be outdated');
139+
});
119140
});

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

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import {
1010
AuthProvider,
1111
DataProvider,
1212
I18nProvider,
13+
IsOffline,
14+
ListBaseProps,
1315
mergeTranslations,
1416
useLocaleState,
1517
} from '../..';
18+
import { onlineManager } from '@tanstack/react-query';
1619

1720
export default {
1821
title: 'ra-core/controller/list/ListBase',
@@ -48,7 +51,11 @@ const data = {
4851
],
4952
};
5053

51-
const defaultDataProvider = fakeRestProvider(data, true, 300);
54+
const defaultDataProvider = fakeRestProvider(
55+
data,
56+
process.env.NODE_ENV !== 'test',
57+
300
58+
);
5259

5360
const BookListView = () => {
5461
const {
@@ -333,7 +340,7 @@ export const WithRenderProps = ({
333340
</div>
334341
);
335342
}}
336-
></ListBase>
343+
/>
337344
</CoreAdminContext>
338345
);
339346

@@ -347,6 +354,89 @@ DefaultTitle.argTypes = {
347354
},
348355
};
349356

357+
export const Offline = ({
358+
dataProvider = defaultDataProvider,
359+
isOnline = true,
360+
...props
361+
}: {
362+
dataProvider?: DataProvider;
363+
isOnline?: boolean;
364+
} & Partial<ListBaseProps>) => {
365+
React.useEffect(() => {
366+
onlineManager.setOnline(isOnline);
367+
}, [isOnline]);
368+
return (
369+
<CoreAdminContext dataProvider={dataProvider}>
370+
<ListBase
371+
resource="books"
372+
perPage={5}
373+
{...props}
374+
offline={<p>You are offline, cannot load data</p>}
375+
render={controllerProps => {
376+
const {
377+
data,
378+
error,
379+
isPending,
380+
page,
381+
perPage,
382+
setPage,
383+
total,
384+
} = controllerProps;
385+
if (isPending) {
386+
return <div>Loading...</div>;
387+
}
388+
if (error) {
389+
return <div>Error...</div>;
390+
}
391+
392+
return (
393+
<div>
394+
<p>
395+
Use the story controls to simulate offline mode:
396+
</p>
397+
<IsOffline>
398+
<p style={{ color: 'orange' }}>
399+
You are offline, the data may be outdated
400+
</p>
401+
</IsOffline>
402+
<button
403+
disabled={page <= 1}
404+
onClick={() => setPage(page - 1)}
405+
>
406+
previous
407+
</button>
408+
<span>
409+
Page {page} of {Math.ceil(total / perPage)}
410+
</span>
411+
<button
412+
disabled={page >= total / perPage}
413+
onClick={() => setPage(page + 1)}
414+
>
415+
next
416+
</button>
417+
<ul>
418+
{data.map((record: any) => (
419+
<li key={record.id}>{record.title}</li>
420+
))}
421+
</ul>
422+
</div>
423+
);
424+
}}
425+
/>
426+
</CoreAdminContext>
427+
);
428+
};
429+
430+
Offline.args = {
431+
isOnline: true,
432+
};
433+
434+
Offline.argTypes = {
435+
isOnline: {
436+
control: { type: 'boolean' },
437+
},
438+
};
439+
350440
const Title = () => {
351441
const { defaultTitle } = useListContext();
352442
const [locale, setLocale] = useLocaleState();

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ import { useIsAuthPending } from '../../auth';
4646
*/
4747
export const ListBase = <RecordType extends RaRecord = any>({
4848
children,
49+
disableAuthentication,
4950
render,
50-
loading = null,
51+
loading,
52+
offline,
5153
...props
5254
}: ListBaseProps<RecordType>) => {
5355
const controllerProps = useListController<RecordType>(props);
@@ -56,20 +58,38 @@ export const ListBase = <RecordType extends RaRecord = any>({
5658
action: 'list',
5759
});
5860

59-
if (isAuthPending && !props.disableAuthentication) {
60-
return loading;
61-
}
6261
if (!render && !children) {
6362
throw new Error(
6463
"<ListBase> requires either a 'render' prop or 'children' prop"
6564
);
6665
}
6766

67+
const showLoading =
68+
isAuthPending &&
69+
!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+
6882
return (
6983
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
7084
<OptionalResourceContextProvider value={props.resource}>
7185
<ListContextProvider value={controllerProps}>
72-
{render ? render(controllerProps) : children}
86+
{showLoading
87+
? loading
88+
: showOffline
89+
? offline
90+
: render
91+
? render(controllerProps)
92+
: children}
7393
</ListContextProvider>
7494
</OptionalResourceContextProvider>
7595
);
@@ -80,4 +100,5 @@ export interface ListBaseProps<RecordType extends RaRecord = any>
80100
children?: ReactNode;
81101
render?: (props: ListControllerResult<RecordType, Error>) => ReactNode;
82102
loading?: ReactNode;
103+
offline?: ReactNode;
83104
}

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

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
screen,
88
act,
99
} from '@testing-library/react';
10-
import { onlineManager } from '@tanstack/react-query';
1110
import { testDataProvider } from '../../dataProvider';
1211
import { memoryStore } from '../../store';
1312
import { CoreAdminContext } from '../../core';
@@ -23,11 +22,7 @@ import {
2322
CanAccess,
2423
DisableAuthentication,
2524
} from './useListController.security.stories';
26-
import {
27-
Basic,
28-
defaultDataProvider,
29-
Offline,
30-
} from './useListController.stories';
25+
import { Basic, defaultDataProvider } from './useListController.stories';
3126

3227
describe('useListController', () => {
3328
const defaultProps = {
@@ -36,10 +31,6 @@ describe('useListController', () => {
3631
debounce: 200,
3732
};
3833

39-
beforeEach(() => {
40-
onlineManager.setOnline(true);
41-
});
42-
4334
describe('queryOptions', () => {
4435
it('should accept custom client query options', async () => {
4536
jest.spyOn(console, 'error').mockImplementationOnce(() => {});
@@ -694,37 +685,6 @@ describe('useListController', () => {
694685
});
695686
});
696687

697-
describe('offline', () => {
698-
it('should display a warning if showing placeholder data when offline', async () => {
699-
render(<Offline />);
700-
fireEvent.click(await screen.findByText('Go online'));
701-
await screen.findByText('1 - Morbi suscipit malesuada');
702-
fireEvent.click(await screen.findByText('Go offline'));
703-
fireEvent.click(await screen.findByText('Page 2'));
704-
await screen.findByText(
705-
'ra.message.placeholder_data_warning - warning'
706-
);
707-
});
708-
709-
it('should not display a warning if showing stale data when offline', async () => {
710-
render(<Offline />);
711-
fireEvent.click(await screen.findByText('Go online'));
712-
await screen.findByText('1 - Morbi suscipit malesuada');
713-
fireEvent.click(await screen.findByText('Page 2'));
714-
await screen.findByText('4 - Integer commodo est');
715-
fireEvent.click(await screen.findByText('Page 1'));
716-
await screen.findByText('1 - Morbi suscipit malesuada');
717-
fireEvent.click(await screen.findByText('Go offline'));
718-
fireEvent.click(await screen.findByText('Page 2'));
719-
await screen.findByText('4 - Integer commodo est');
720-
expect(
721-
screen.queryByText(
722-
'ra.message.placeholder_data_warning - warning'
723-
)
724-
).toBeNull();
725-
});
726-
});
727-
728688
describe('response metadata', () => {
729689
it('should return response metadata as meta', async () => {
730690
const getList = jest.fn().mockImplementation(() =>

0 commit comments

Comments
 (0)