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 +

+
+ + + Page {page} of {Math.ceil(total / perPage)} + + +
    + {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 +

+
+ + + Page {page} of {Math.ceil(total / perPage)} + + +
    + {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 ( -
- - -

{online ? 'Online' : 'Offline'}

-
- ); -}; - -const Notifications = () => { - const { notifications, takeNotification } = useNotificationContext(); - return ( -
-

NOTIFICATIONS

-
    - {notifications.map(({ message, type }, id) => ( -
  • - {message} - {type} -
  • - ))} -
- -
- ); -}; - -export const Offline = () => ( - - - - - {params => ( -
-
- - - -
-
    - {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; }