diff --git a/docs/InfiniteList.md b/docs/InfiniteList.md
index 2b8ff6cf84f..7244b9d9ecb 100644
--- a/docs/InfiniteList.md
+++ b/docs/InfiniteList.md
@@ -72,6 +72,7 @@ The props are the same as [the `` component](./List.md):
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
+| `offline` | Optional | `ReactNode` | `` | The component to render when there is no connectivity and there is no data in the cache |
| `pagination` | Optional | `ReactElement` | `` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
@@ -85,6 +86,49 @@ Check the [`` component](./List.md) for details about each prop.
Additional props are passed down to the root component (a MUI `` by default).
+## `offline`
+
+By default, `` renders the `` 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:
+
+```jsx
+import { InfiniteList, InfinitePagination } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts. ;
+// The offline component may be displayed at the bottom of the page if the network connectivity is lost
+// when loading new pages. Make sure you pass your custom offline component here too
+const pagination = ;
+
+export const PostList = () => (
+
+ ...
+
+);
+```
+
+**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 `` component:
+
+```jsx
+import { InfiniteList, InfinitePagination, IsOffline } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts. ;
+// The offline component may be displayed at the bottom of the page if the network connectivity is lost
+// when loading new pages. Make sure you pass your custom offline component here too
+const pagination = ;
+
+export const PostList = () => (
+
+
+
+ You are offline, the data may be outdated
+
+
+ ...
+
+);
+```
+
## `pagination`
You can replace the default "load on scroll" pagination (triggered by a component named ``) by a custom pagination component. To get the pagination state and callbacks, you'll need to read the `InfinitePaginationContext`.
diff --git a/docs/List.md b/docs/List.md
index e51b730bb14..8bddc72d1c3 100644
--- a/docs/List.md
+++ b/docs/List.md
@@ -69,6 +69,7 @@ You can find more advanced examples of `` usage in the [demos](./Demos.md)
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
+| `offline` | Optional | `ReactNode` | `` | The component to render when there is no connectivity and there is no data in the cache |
| `pagination` | Optional | `ReactElement` | `` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
@@ -774,6 +775,43 @@ export const PostList = () => (
const filterSentToDataProvider = { ...filterDefaultValues, ...filterChosenByUser, ...filter };
```
+## `offline`
+
+By default, `` renders the `` 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:
+
+```jsx
+import { List } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts. ;
+
+export const PostList = () => (
+
+ ...
+
+);
+```
+
+**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 `` component:
+
+```jsx
+import { List, IsOffline } from 'react-admin';
+import { Alert } from '@mui/material';
+
+const offline = No network. Could not load the posts. ;
+
+export const PostList = () => (
+
+
+
+ You are offline, the data may be outdated
+
+
+ ...
+
+);
+```
+
## `pagination`
By default, the `` view displays a set of pagination controls at the bottom of the list.
diff --git a/docs/ListBase.md b/docs/ListBase.md
index b23d972f27e..19d5f89056a 100644
--- a/docs/ListBase.md
+++ b/docs/ListBase.md
@@ -87,6 +87,7 @@ The `` component accepts the following props:
* [`exporter`](./List.md#exporter)
* [`filter`](./List.md#filter-permanent-filter)
* [`filterDefaultValues`](./List.md#filterdefaultvalues)
+* [`offline`](./List.md#offline)
* [`perPage`](./List.md#perpage)
* [`queryOptions`](./List.md#queryoptions)
* `render`
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
index c471272b73e..e9032a7f5a7 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx
@@ -73,6 +73,29 @@ describe('InfiniteListBase', () => {
resolveAuth!();
await screen.findByText('Hello');
});
+ it('should not wait for the authentication resolution before loading data when disableAuthentication is true', async () => {
+ const authProvider = {
+ login: () => Promise.resolve(),
+ logout: () => Promise.resolve(),
+ checkError: () => Promise.resolve(),
+ checkAuth: jest.fn(),
+ };
+ const dataProvider = testDataProvider({
+ // @ts-ignore
+ getList: jest.fn(() =>
+ Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 })
+ ),
+ });
+ render(
+
+ );
+ await screen.findByText('Hello');
+ expect(authProvider.checkAuth).not.toHaveBeenCalled();
+ });
it('should wait for both the authentication and authorization resolution before loading data', async () => {
let resolveAuth: () => void;
let resolveCanAccess: (value: boolean) => void;
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
index 3c23bb6a8c8..368acb45318 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx
@@ -3,7 +3,7 @@ import fakeRestProvider from 'ra-data-fakerest';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import polyglotI18nProvider from 'ra-i18n-polyglot';
-import { InfiniteListBase } from './InfiniteListBase';
+import { InfiniteListBase, InfiniteListBaseProps } from './InfiniteListBase';
import { CoreAdminContext } from '../../core';
import { useListContext } from './useListContext';
import { useInfinitePaginationContext } from './useInfinitePaginationContext';
@@ -11,9 +11,12 @@ import {
AuthProvider,
DataProvider,
I18nProvider,
+ IsOffline,
mergeTranslations,
+ TestMemoryRouter,
useLocaleState,
} from '../..';
+import { onlineManager } from '@tanstack/react-query';
export default {
title: 'ra-core/controller/list/InfiniteListBase',
@@ -140,15 +143,18 @@ export const WithAuthProviderNoAccessControl = ({
checkError: () => Promise.resolve(),
},
dataProvider = defaultDataProvider,
+ InfiniteListProps,
}: {
authProvider?: AuthProvider;
dataProvider?: DataProvider;
+ InfiniteListProps?: Partial;
}) => (
Authentication loading...}
+ {...InfiniteListProps}
>
@@ -314,6 +320,93 @@ export const WithRenderProps = () => (
);
+export const Offline = ({
+ dataProvider = defaultDataProvider,
+ isOnline = true,
+ ...props
+}: {
+ dataProvider?: DataProvider;
+ isOnline?: boolean;
+} & Partial) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+
+ You are offline, cannot load data
}
+ render={controllerProps => {
+ const {
+ data,
+ error,
+ isPending,
+ page,
+ perPage,
+ setPage,
+ total,
+ } = controllerProps;
+ if (isPending) {
+ return Loading...
;
+ }
+ if (error) {
+ return Error...
;
+ }
+
+ return (
+
+
+ Use the story controls to simulate offline
+ mode:
+
+
+
+ You are offline, the data may be
+ outdated
+
+
+
setPage(page - 1)}
+ >
+ previous
+
+
+ Page {page} of {Math.ceil(total / perPage)}
+
+
= total / perPage}
+ onClick={() => setPage(page + 1)}
+ >
+ next
+
+
+ {data.map((record: any) => (
+ {record.title}
+ ))}
+
+
+ );
+ }}
+ />
+
+
+ );
+};
+
+Offline.args = {
+ isOnline: true,
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+};
+
const Title = () => {
const { defaultTitle } = useListContext();
const [locale, setLocale] = useLocaleState();
diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
index b9c7e511860..e57697f3dc8 100644
--- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx
+++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx
@@ -48,7 +48,8 @@ import { useIsAuthPending } from '../../auth';
export const InfiniteListBase = ({
children,
render,
- loading = null,
+ loading,
+ offline,
...props
}: InfiniteListBaseProps) => {
const controllerProps = useInfiniteListController(props);
@@ -57,16 +58,27 @@ export const InfiniteListBase = ({
action: 'list',
});
- if (isAuthPending && !props.disableAuthentication) {
- return loading;
- }
-
if (!render && !children) {
throw new Error(
" requires either a 'render' prop or 'children' prop"
);
}
+ const showLoading =
+ isAuthPending &&
+ !props.disableAuthentication &&
+ loading !== undefined &&
+ loading !== false;
+
+ const { isPaused, isPending, isPlaceholderData } = controllerProps;
+ const showOffline =
+ isPaused &&
+ // If isPending and isPaused are true, we are offline and couldn't even load the initial data
+ // If isPaused and isPlaceholderData are true, we are offline and couldn't even load data with different parameters on the same useQuery observer
+ (isPending || isPlaceholderData) &&
+ offline !== undefined &&
+ offline !== false;
+
return (
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
@@ -82,7 +94,13 @@ export const InfiniteListBase = ({
controllerProps.isFetchingPreviousPage,
}}
>
- {render ? render(controllerProps) : children}
+ {showLoading
+ ? loading
+ : showOffline
+ ? offline
+ : render
+ ? render(controllerProps)
+ : children}
@@ -93,5 +111,6 @@ export interface InfiniteListBaseProps
extends InfiniteListControllerProps {
loading?: ReactNode;
children?: ReactNode;
+ offline?: ReactNode;
render?: (props: InfiniteListControllerResult) => ReactNode;
}
diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx
index a4785fcd659..0b096048cb7 100644
--- a/packages/ra-core/src/controller/list/ListBase.spec.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx
@@ -4,6 +4,7 @@ import {
AccessControl,
DefaultTitle,
NoAuthProvider,
+ Offline,
WithAuthProviderNoAccessControl,
WithRenderProps,
} from './ListBase.stories';
@@ -21,6 +22,29 @@ describe('ListBase', () => {
expect(dataProvider.getList).toHaveBeenCalled();
await screen.findByText('Hello');
});
+ it('should not wait for the authentication resolution before loading data when disableAuthentication is true', async () => {
+ const authProvider = {
+ login: () => Promise.resolve(),
+ logout: () => Promise.resolve(),
+ checkError: () => Promise.resolve(),
+ checkAuth: jest.fn(),
+ };
+ const dataProvider = testDataProvider({
+ // @ts-ignore
+ getList: jest.fn(() =>
+ Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 })
+ ),
+ });
+ render(
+
+ );
+ await screen.findByText('Hello');
+ expect(authProvider.checkAuth).not.toHaveBeenCalled();
+ });
it('should wait for the authentication resolution before loading data', async () => {
let resolveAuth: () => void;
const authProvider = {
@@ -116,4 +140,24 @@ describe('ListBase', () => {
expect(dataProvider.getList).toHaveBeenCalled();
await screen.findByText('Hello');
});
+
+ it('should render the offline prop node when offline', async () => {
+ const { rerender } = render( );
+ await screen.findByText('You are offline, cannot load data');
+ rerender( );
+ await screen.findByText('War and Peace');
+ expect(
+ screen.queryByText('You are offline, cannot load data')
+ ).toBeNull();
+ rerender( );
+ await screen.findByText('You are offline, the data may be outdated');
+ fireEvent.click(screen.getByText('next'));
+ await screen.findByText('You are offline, cannot load data');
+ rerender( );
+ await screen.findByText('And Then There Were None');
+ rerender( );
+ fireEvent.click(screen.getByText('previous'));
+ await screen.findByText('War and Peace');
+ await screen.findByText('You are offline, the data may be outdated');
+ });
});
diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx
index 5ffe3f678e8..2fbf0cf335c 100644
--- a/packages/ra-core/src/controller/list/ListBase.stories.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx
@@ -10,9 +10,12 @@ import {
AuthProvider,
DataProvider,
I18nProvider,
+ IsOffline,
+ ListBaseProps,
mergeTranslations,
useLocaleState,
} from '../..';
+import { onlineManager } from '@tanstack/react-query';
export default {
title: 'ra-core/controller/list/ListBase',
@@ -48,7 +51,11 @@ const data = {
],
};
-const defaultDataProvider = fakeRestProvider(data, true, 300);
+const defaultDataProvider = fakeRestProvider(
+ data,
+ process.env.NODE_ENV !== 'test',
+ 300
+);
const BookListView = () => {
const {
@@ -140,15 +147,18 @@ export const WithAuthProviderNoAccessControl = ({
checkError: () => Promise.resolve(),
},
dataProvider = defaultDataProvider,
+ ListProps,
}: {
authProvider?: AuthProvider;
dataProvider?: DataProvider;
+ ListProps?: Partial;
}) => (
Authentication loading...}
+ {...ListProps}
>
@@ -333,7 +343,7 @@ export const WithRenderProps = ({
);
}}
- >
+ />
);
@@ -347,6 +357,89 @@ DefaultTitle.argTypes = {
},
};
+export const Offline = ({
+ dataProvider = defaultDataProvider,
+ isOnline = true,
+ ...props
+}: {
+ dataProvider?: DataProvider;
+ isOnline?: boolean;
+} & Partial) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+ You are offline, cannot load data}
+ render={controllerProps => {
+ const {
+ data,
+ error,
+ isPending,
+ page,
+ perPage,
+ setPage,
+ total,
+ } = controllerProps;
+ if (isPending) {
+ return Loading...
;
+ }
+ if (error) {
+ return Error...
;
+ }
+
+ return (
+
+
+ Use the story controls to simulate offline mode:
+
+
+
+ You are offline, the data may be outdated
+
+
+
setPage(page - 1)}
+ >
+ previous
+
+
+ Page {page} of {Math.ceil(total / perPage)}
+
+
= total / perPage}
+ onClick={() => setPage(page + 1)}
+ >
+ next
+
+
+ {data.map((record: any) => (
+ {record.title}
+ ))}
+
+
+ );
+ }}
+ />
+
+ );
+};
+
+Offline.args = {
+ isOnline: true,
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+};
+
const Title = () => {
const { defaultTitle } = useListContext();
const [locale, setLocale] = useLocaleState();
diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx
index 684e15fdc6b..c4417e58bd9 100644
--- a/packages/ra-core/src/controller/list/ListBase.tsx
+++ b/packages/ra-core/src/controller/list/ListBase.tsx
@@ -47,7 +47,8 @@ import { useIsAuthPending } from '../../auth';
export const ListBase = ({
children,
render,
- loading = null,
+ loading,
+ offline,
...props
}: ListBaseProps) => {
const controllerProps = useListController(props);
@@ -56,20 +57,38 @@ export const ListBase = ({
action: 'list',
});
- if (isAuthPending && !props.disableAuthentication) {
- return loading;
- }
if (!render && !children) {
throw new Error(
" requires either a 'render' prop or 'children' prop"
);
}
+ const showLoading =
+ isAuthPending &&
+ !props.disableAuthentication &&
+ loading !== undefined &&
+ loading !== false;
+
+ const { isPaused, isPending, isPlaceholderData } = controllerProps;
+ const showOffline =
+ isPaused &&
+ // If isPending and isPaused are true, we are offline and couldn't even load the initial data
+ // If isPaused and isPlaceholderData are true, we are offline and couldn't even load data with different parameters on the same useQuery observer
+ (isPending || isPlaceholderData) &&
+ offline !== undefined &&
+ offline !== false;
+
return (
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
- {render ? render(controllerProps) : children}
+ {showLoading
+ ? loading
+ : showOffline
+ ? offline
+ : render
+ ? render(controllerProps)
+ : children}
);
@@ -80,4 +99,5 @@ export interface ListBaseProps
children?: ReactNode;
render?: (props: ListControllerResult) => ReactNode;
loading?: ReactNode;
+ offline?: ReactNode;
}
diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts
index 8214e3ba9d0..23662d68042 100644
--- a/packages/ra-core/src/controller/list/useInfiniteListController.ts
+++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts
@@ -102,7 +102,9 @@ export const useInfiniteListController = <
total,
error,
isLoading,
+ isPaused,
isPending,
+ isPlaceholderData,
isFetching,
hasNextPage,
hasPreviousPage,
@@ -204,7 +206,9 @@ export const useInfiniteListController = <
hideFilter: queryModifiers.hideFilter,
isFetching,
isLoading,
+ isPaused,
isPending,
+ isPlaceholderData,
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
diff --git a/packages/ra-core/src/controller/list/useListController.spec.tsx b/packages/ra-core/src/controller/list/useListController.spec.tsx
index 3cf448baf26..40ffa45d15d 100644
--- a/packages/ra-core/src/controller/list/useListController.spec.tsx
+++ b/packages/ra-core/src/controller/list/useListController.spec.tsx
@@ -7,7 +7,6 @@ import {
screen,
act,
} from '@testing-library/react';
-import { onlineManager } from '@tanstack/react-query';
import { testDataProvider } from '../../dataProvider';
import { memoryStore } from '../../store';
import { CoreAdminContext } from '../../core';
@@ -23,11 +22,7 @@ import {
CanAccess,
DisableAuthentication,
} from './useListController.security.stories';
-import {
- Basic,
- defaultDataProvider,
- Offline,
-} from './useListController.stories';
+import { Basic, defaultDataProvider } from './useListController.stories';
describe('useListController', () => {
const defaultProps = {
@@ -36,10 +31,6 @@ describe('useListController', () => {
debounce: 200,
};
- beforeEach(() => {
- onlineManager.setOnline(true);
- });
-
describe('queryOptions', () => {
it('should accept custom client query options', async () => {
jest.spyOn(console, 'error').mockImplementationOnce(() => {});
@@ -694,37 +685,6 @@ describe('useListController', () => {
});
});
- describe('offline', () => {
- it('should display a warning if showing placeholder data when offline', async () => {
- render( );
- fireEvent.click(await screen.findByText('Go online'));
- await screen.findByText('1 - Morbi suscipit malesuada');
- fireEvent.click(await screen.findByText('Go offline'));
- fireEvent.click(await screen.findByText('Page 2'));
- await screen.findByText(
- 'ra.message.placeholder_data_warning - warning'
- );
- });
-
- it('should not display a warning if showing stale data when offline', async () => {
- render( );
- fireEvent.click(await screen.findByText('Go online'));
- await screen.findByText('1 - Morbi suscipit malesuada');
- fireEvent.click(await screen.findByText('Page 2'));
- await screen.findByText('4 - Integer commodo est');
- fireEvent.click(await screen.findByText('Page 1'));
- await screen.findByText('1 - Morbi suscipit malesuada');
- fireEvent.click(await screen.findByText('Go offline'));
- fireEvent.click(await screen.findByText('Page 2'));
- await screen.findByText('4 - Integer commodo est');
- expect(
- screen.queryByText(
- 'ra.message.placeholder_data_warning - warning'
- )
- ).toBeNull();
- });
- });
-
describe('response metadata', () => {
it('should return response metadata as meta', async () => {
const getList = jest.fn().mockImplementation(() =>
diff --git a/packages/ra-core/src/controller/list/useListController.stories.tsx b/packages/ra-core/src/controller/list/useListController.stories.tsx
index a90b4e1ad69..d837c2bbea1 100644
--- a/packages/ra-core/src/controller/list/useListController.stories.tsx
+++ b/packages/ra-core/src/controller/list/useListController.stories.tsx
@@ -1,12 +1,10 @@
import * as React from 'react';
import fakeDataProvider from 'ra-data-fakerest';
-import { onlineManager } from '@tanstack/react-query';
import { CoreAdminContext } from '../../core';
import { ListController } from './ListController';
import type { DataProvider } from '../../types';
import type { ListControllerResult } from './useListController';
-import { useNotificationContext } from '../../notification';
import { TestMemoryRouter } from '../..';
export default {
@@ -102,92 +100,3 @@ export const Basic = ({
);
-
-const OnlineManager = () => {
- const [online, setOnline] = React.useState(onlineManager.isOnline());
- React.useEffect(() => {
- const unsubscribe = onlineManager.subscribe(isOnline => {
- setOnline(isOnline);
- });
- return unsubscribe;
- }, []);
- return (
-
-
{
- onlineManager.setOnline(true);
- }}
- >
- Go online
-
-
{
- onlineManager.setOnline(false);
- }}
- >
- Go offline
-
-
{online ? 'Online' : 'Offline'}
-
- );
-};
-
-const Notifications = () => {
- const { notifications, takeNotification } = useNotificationContext();
- return (
-
-
NOTIFICATIONS
-
- {notifications.map(({ message, type }, id) => (
-
- {message} - {type}
-
- ))}
-
-
Take notification
-
- );
-};
-
-export const Offline = () => (
-
-
-
-
- {params => (
-
-
- params.setPage(1)}>
- Page 1
-
- params.setPage(2)}>
- Page 2
-
- params.setPage(3)}>
- Page 3
-
-
-
- {params.data?.map(record => (
-
- {record.id} - {record.title}
-
- ))}
-
-
- )}
-
-
-
-
-);
diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts
index 164b5d154fb..87ddaca534e 100644
--- a/packages/ra-core/src/controller/list/useListController.ts
+++ b/packages/ra-core/src/controller/list/useListController.ts
@@ -148,16 +148,6 @@ export const useListController = <
...otherQueryOptions,
}
);
- useEffect(() => {
- if (isPaused && isPlaceholderData) {
- notify('ra.message.placeholder_data_warning', {
- type: 'warning',
- messageArgs: {
- _: 'Network issue: Data refresh failed.',
- },
- });
- }
- }, [isPaused, isPlaceholderData, notify]);
// change page if there is no data
useEffect(() => {
@@ -216,7 +206,9 @@ export const useListController = <
hideFilter: queryModifiers.hideFilter,
isFetching,
isLoading,
+ isPaused,
isPending,
+ isPlaceholderData,
onSelect: selectionModifiers.select,
onSelectAll,
onToggleItem: selectionModifiers.toggle,
diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
index 7aeba9b2dd1..f5cfc5d0bf7 100644
--- a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
+++ b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx
@@ -7,8 +7,16 @@ import {
useListContext,
useInfinitePaginationContext,
TestMemoryRouter,
+ IsOffline,
} from 'ra-core';
-import { Box, Button, Card, ThemeOptions, Typography } from '@mui/material';
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ ThemeOptions,
+ Typography,
+} from '@mui/material';
import { InfiniteList } from './InfiniteList';
import { SimpleList } from './SimpleList';
import { DataTable, type DataTableProps } from './datatable';
@@ -24,6 +32,7 @@ import { TopToolbar, Layout } from '../layout';
import { BulkActionsToolbar } from './BulkActionsToolbar';
import { deepmerge } from '@mui/utils';
import { defaultLightTheme } from '../theme';
+import { onlineManager } from '@tanstack/react-query';
export default {
title: 'ra-ui-materialui/list/InfiniteList',
@@ -522,3 +531,82 @@ export const WithRenderProp = () => (
/>
);
+
+export const Offline = ({
+ isOnline = true,
+ offline,
+ pagination,
+}: {
+ isOnline?: boolean;
+ offline?: React.ReactNode;
+ pagination?: React.ReactNode;
+}) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+ (
+
+
+
+ )}
+ />
+
+ );
+};
+
+const BookListOffline = () => {
+ const { error, isPending } = useListContext();
+ if (isPending) {
+ return Loading...
;
+ }
+ if (error) {
+ return Error: {error.message}
;
+ }
+ return (
+ <>
+
+
+ You are offline, the data may be outdated
+
+
+
+ >
+ );
+};
+
+const CustomOffline = () => {
+ return You are offline! ;
+};
+
+Offline.args = {
+ isOnline: true,
+ offline: 'default',
+ pagination: 'infinite',
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+ pagination: {
+ control: { type: 'radio' },
+ options: ['infinite', 'classic'],
+ mapping: {
+ infinite: ,
+ classic: ,
+ },
+ },
+ offline: {
+ name: 'Offline component',
+ control: { type: 'radio' },
+ options: ['default', 'custom'],
+ mapping: {
+ default: undefined,
+ custom: ,
+ },
+ },
+};
diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
index 54248d269e7..349b51a8780 100644
--- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx
+++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx
@@ -103,6 +103,8 @@ export const InfiniteList = (
resource={resource}
sort={sort}
storeKey={storeKey}
+ // Disable offline support from InfiniteListBase as it is handled by ListView to keep the ListView container
+ offline={false}
>
{...rest} pagination={pagination} />
diff --git a/packages/ra-ui-materialui/src/list/List.spec.tsx b/packages/ra-ui-materialui/src/list/List.spec.tsx
index 093de214ff0..4224ed9e561 100644
--- a/packages/ra-ui-materialui/src/list/List.spec.tsx
+++ b/packages/ra-ui-materialui/src/list/List.spec.tsx
@@ -24,7 +24,9 @@ import {
SelectAllLimit,
Themed,
WithRenderProp,
+ Offline,
} from './List.stories';
+import { Alert } from '@mui/material';
const theme = createTheme(defaultTheme);
@@ -508,4 +510,37 @@ describe('
', () => {
);
});
});
+ it('should render the default offline component node when offline', async () => {
+ const { rerender } = render( );
+ await screen.findByText('No connectivity. Could not fetch data.');
+ rerender( );
+ await screen.findByText('War and Peace (1869)');
+ expect(
+ screen.queryByText('No connectivity. Could not fetch data.')
+ ).toBeNull();
+ rerender( );
+ await screen.findByText('You are offline, the data may be outdated');
+ fireEvent.click(screen.getByLabelText('Go to page 2'));
+ await screen.findByText('No connectivity. Could not fetch data.');
+ fireEvent.click(screen.getByLabelText('Go to page 1'));
+ await screen.findByText('War and Peace (1869)');
+ });
+ it('should render the custom offline component node when offline', async () => {
+ const CustomOffline = () => {
+ return You are offline! ;
+ };
+ const { rerender } = render(
+ } />
+ );
+ await screen.findByText('You are offline!');
+ rerender( } />);
+ await screen.findByText('War and Peace (1869)');
+ expect(screen.queryByText('You are offline!')).toBeNull();
+ rerender( } />);
+ await screen.findByText('You are offline, the data may be outdated');
+ fireEvent.click(screen.getByLabelText('Go to page 2'));
+ await screen.findByText('You are offline!');
+ fireEvent.click(screen.getByLabelText('Go to page 1'));
+ await screen.findByText('War and Peace (1869)');
+ });
});
diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx
index 0674c13052f..4d3e02287ba 100644
--- a/packages/ra-ui-materialui/src/list/List.stories.tsx
+++ b/packages/ra-ui-materialui/src/list/List.stories.tsx
@@ -8,6 +8,7 @@ import {
DataProvider,
GetListParams,
WithListContext,
+ IsOffline,
} from 'ra-core';
import fakeRestDataProvider from 'ra-data-fakerest';
import {
@@ -17,6 +18,7 @@ import {
Button,
Link as MuiLink,
ThemeOptions,
+ Alert,
} from '@mui/material';
import { List } from './List';
import { SimpleList } from './SimpleList';
@@ -29,8 +31,9 @@ import { BulkDeleteButton, ListButton, SelectAllButton } from '../button';
import { ShowGuesser } from '../detail';
import TopToolbar from '../layout/TopToolbar';
import { BulkActionsToolbar } from './BulkActionsToolbar';
-import { deepmerge } from '@mui/utils';
import { defaultLightTheme } from '../theme';
+import { onlineManager } from '@tanstack/react-query';
+import { deepmerge } from '@mui/utils';
export default { title: 'ra-ui-materialui/list/List' };
@@ -202,6 +205,21 @@ export const Actions = () => (
);
+export const NoActions = () => (
+
+
+ (
+
+
+
+ )}
+ />
+
+
+);
+
export const Filters = () => (
@@ -917,3 +935,77 @@ export const WithRenderProp = () => (
);
+
+export const Offline = ({
+ isOnline = true,
+ offline,
+}: {
+ isOnline?: boolean;
+ offline?: React.ReactNode;
+}) => {
+ React.useEffect(() => {
+ onlineManager.setOnline(isOnline);
+ }, [isOnline]);
+ return (
+
+
+ (
+
+
+
+ )}
+ />
+
+
+ );
+};
+
+const BookListOffline = () => {
+ const { error, isPending } = useListContext();
+ if (isPending) {
+ return Loading...
;
+ }
+ if (error) {
+ return Error: {error.message}
;
+ }
+ return (
+ <>
+
+
+ You are offline, the data may be outdated
+
+
+ record.year}
+ />
+ >
+ );
+};
+
+const CustomOffline = () => {
+ return You are offline! ;
+};
+
+Offline.args = {
+ isOnline: true,
+ offline: 'default',
+};
+
+Offline.argTypes = {
+ isOnline: {
+ control: { type: 'boolean' },
+ },
+ offline: {
+ name: 'Offline component',
+ control: { type: 'radio' },
+ options: ['default', 'custom'],
+ mapping: {
+ default: undefined,
+ custom: ,
+ },
+ },
+};
diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx
index e42d07dfed0..082be5293bf 100644
--- a/packages/ra-ui-materialui/src/list/List.tsx
+++ b/packages/ra-ui-materialui/src/list/List.tsx
@@ -100,6 +100,8 @@ export const List = (
resource={resource}
sort={sort}
storeKey={storeKey}
+ // Disable offline support from ListBase as it is handled by ListView to keep the ListView container
+ offline={false}
>
{...rest} render={render} />
diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx
index 7003280cb76..8dfd7dabbb4 100644
--- a/packages/ra-ui-materialui/src/list/ListView.tsx
+++ b/packages/ra-ui-materialui/src/list/ListView.tsx
@@ -16,11 +16,13 @@ import { Pagination as DefaultPagination } from './pagination';
import { ListActions as DefaultActions } from './ListActions';
import { Empty } from './Empty';
import { ListProps } from './List';
+import { Offline } from '../Offline';
const defaultActions = ;
const defaultPagination = ;
const defaultEmpty = ;
const DefaultComponent = Card;
+const defaultOffline = ;
export const ListView = (
props: ListViewProps
@@ -37,6 +39,7 @@ export const ListView = (
title,
empty = defaultEmpty,
render,
+ offline = defaultOffline,
...rest
} = props;
const listContext = useListContext();
@@ -44,7 +47,9 @@ export const ListView = (
defaultTitle,
data,
error,
+ isPaused,
isPending,
+ isPlaceholderData,
filterValues,
resource,
total,
@@ -52,21 +57,38 @@ export const ListView = (
hasPreviousPage,
} = listContext;
- if ((!children && !render) || (!data && isPending && emptyWhileLoading)) {
+ const showOffline =
+ isPaused &&
+ (isPending || isPlaceholderData) &&
+ offline !== false &&
+ offline !== undefined;
+
+ if (
+ (!children && !render) ||
+ (!data && isPending && !isPaused && emptyWhileLoading)
+ ) {
return null;
}
const renderList = () => (
-
- {(filters || actions) && (
+
+ {filters || actions ? (
- )}
+ ) : null}
- {render ? render(listContext) : children}
+ {showOffline
+ ? offline
+ : render
+ ? render(listContext)
+ : children}
{!error && pagination !== false && pagination}
@@ -301,6 +323,24 @@ export interface ListViewProps
{
*/
filters?: ReactElement | ReactElement[];
+ /**
+ * The offline component to display. defaults to
+ *
+ * @see https://marmelab.com/react-admin/List.html#offline
+ * @example
+ * import { List } from 'react-admin';
+ * import { Alert } from '@mui/material';
+ *
+ * const offline = No internet connection. Could not load data. ;
+ *
+ * export const PostList = () => (
+ *
+ * ...
+ *
+ * );
+ */
+ offline?: ReactNode | false;
+
/**
* The pagination component to display. defaults to
*
@@ -316,7 +356,7 @@ export interface ListViewProps {
*
* );
*/
- pagination?: ReactElement | false;
+ pagination?: ReactNode | false;
/**
* The page title (main title) to display above the data. Defaults to the humanized resource name.
@@ -360,6 +400,7 @@ export const ListClasses = {
main: `${PREFIX}-main`,
content: `${PREFIX}-content`,
actions: `${PREFIX}-actions`,
+ noActions: `${PREFIX}-noActions`,
noResults: `${PREFIX}-noResults`,
};
@@ -383,6 +424,9 @@ const Root = styled('div', {
overflow: 'inherit',
},
+ [`& .${ListClasses.noActions}`]: {
+ marginTop: '1em',
+ },
[`& .${ListClasses.actions}`]: {},
[`& .${ListClasses.noResults}`]: {
diff --git a/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx b/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
index f1be231cddd..a562b97d5fd 100644
--- a/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
+++ b/packages/ra-ui-materialui/src/list/pagination/InfinitePagination.tsx
@@ -6,6 +6,7 @@ import {
useEvent,
} from 'ra-core';
import { Box, CircularProgress, type SxProps, type Theme } from '@mui/material';
+import { Offline } from '../../Offline';
/**
* A pagination component that loads more results when the user scrolls to the bottom of the list.
@@ -25,10 +26,11 @@ import { Box, CircularProgress, type SxProps, type Theme } from '@mui/material';
* );
*/
export const InfinitePagination = ({
+ offline = defaultOffline,
options = defaultOptions,
sx,
}: InfinitePaginationProps) => {
- const { isPending } = useListContext();
+ const { isPaused, isPending } = useListContext();
const { fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePaginationContext();
@@ -38,17 +40,26 @@ export const InfinitePagination = ({
);
}
+ const [hasRequestedNextPage, setHasRequestedNextPage] =
+ React.useState(false);
const observerElem = useRef(null);
-
const handleObserver = useEvent<[IntersectionObserverEntry[]], void>(
entries => {
const [target] = entries;
if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ setHasRequestedNextPage(true);
fetchNextPage();
}
}
);
+ useEffect(() => {
+ // Whenever the query is unpaused, reset the requested next page state
+ if (!isPaused) {
+ setHasRequestedNextPage(false);
+ }
+ }, [isPaused]);
+
useEffect(() => {
const element = observerElem.current;
if (!element) return;
@@ -66,6 +77,13 @@ export const InfinitePagination = ({
if (isPending) return null;
+ const showOffline =
+ isPaused &&
+ hasNextPage &&
+ hasRequestedNextPage &&
+ offline !== false &&
+ offline !== undefined;
+
return (
- {isFetchingNextPage && hasNextPage && (
+ {showOffline ? (
+ offline
+ ) : isFetchingNextPage && hasNextPage ? (
- )}
+ ) : null}
);
};
const defaultOptions = { threshold: 0 };
+const defaultOffline = ;
export interface InfinitePaginationProps {
+ offline?: React.ReactNode;
options?: IntersectionObserverInit;
sx?: SxProps;
}