diff --git a/docs/Show.md b/docs/Show.md index 470a50fb81b..5c43dddee4b 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -70,6 +70,7 @@ That's enough to display the post show view above. | `disable Authentication` | Optional | `boolean` | | Set to `true` to disable the authentication check | `empty WhileLoading` | Optional | `boolean` | | Set to `true` to return `null` while the show is loading | `id` | Optional | `string | number` | | The record id. If not provided, it will be deduced from the URL +| `offline` | Optional | `ReactNode` | | The component to render when there is no connectivity and the record isn't in the cache | `queryOptions` | Optional | `object` | | The options to pass to the `useQuery` hook | `resource` | Optional | `string` | | The resource name, e.g. `posts` | `sx` | Optional | `object` | | Override or extend the styles applied to the component @@ -83,7 +84,7 @@ By default, `` includes an action toolbar with an `` if the `< ```jsx import Button from '@mui/material/Button'; -import { EditButton, TopToolbar } from 'react-admin'; +import { EditButton, Show, TopToolbar } from 'react-admin'; const PostShowActions = () => ( @@ -158,6 +159,8 @@ React-admin provides 2 built-in show layout components: To use an alternative layout, switch the `` child component: ```diff +import { Show } from 'react-admin'; + export const PostShow = () => ( - @@ -188,6 +191,7 @@ You can override the main area container by passing a `component` prop: {% raw %} ```jsx +import { Show } from 'react-admin'; import { Box } from '@mui/material'; const ShowWrapper = ({ children }) => ( @@ -210,6 +214,8 @@ const PostShow = () => ( By default, the `` component will automatically redirect the user to the login page if the user is not authenticated. If you want to disable this behavior and allow anonymous access to a show page, set the `disableAuthentication` prop to `true`. ```jsx +import { Show } from 'react-admin'; + const PostShow = () => ( ... @@ -273,6 +279,8 @@ const BookShow = () => ( By default, `` deduces the identifier of the record to show from the URL path. So under the `/posts/123/show` path, the `id` prop will be `123`. You may want to force a different identifier. In this case, pass a custom `id` prop. ```jsx +import { Show } from 'react-admin'; + export const PostShow = () => ( ... @@ -282,6 +290,38 @@ export const PostShow = () => ( **Tip**: Pass both a custom `id` and a custom `resource` prop to use `` independently of the current URL. This even allows you to use more than one `` component in the same page. +## `offline` + +By default, `` renders the `` component when there is no connectivity and the record hasn't been cached yet. You can provide your own component via the `offline` prop: + +```jsx +import { Show } from 'react-admin'; + +export const PostShow = () => ( + No network. Could not load the post.

}> + ... +
+); +``` + +**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 { Show, IsOffline } from 'react-admin'; +import { Alert } from '@mui/material'; + +export const PostShow = () => ( + No network. Could not load the post.

}> + + + You are offline, the data may be outdated + + + ... +
+); +``` + ## `queryOptions` `` accepts a `queryOptions` prop to pass options to the react-query client. @@ -372,6 +412,8 @@ export const PostShow = () => ( By default, `` operates on the current `ResourceContext` (defined at the routing level), so under the `/posts/1/show` path, the `resource` prop will be `posts`. You may want to force a different resource. In this case, pass a custom `resource` prop, and it will override the `ResourceContext` value. ```jsx +import { Show } from 'react-admin'; + export const UsersShow = () => ( ... diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 7e68a762962..c0c84a6e398 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -63,10 +63,11 @@ const App = () => ( | Prop | Required | Type | Default | Description |------------------|----------|-------------------|---------|-------------------------------------------------------- | `children` | Optional | `ReactNode` | | The components rendering the record fields -| `render` | Optional | `(props: ShowControllerResult) => ReactNode` | | Alternative to children, a function that takes the ShowController context and renders the form +| `render` | Optional | `(props: ShowControllerResult) => ReactNode` | | Alternative to children, a function that takes the ShowController context and renders the form | `disable Authentication` | Optional | `boolean` | | Set to `true` to disable the authentication check -| `empty WhileLoading` | Optional | `boolean` | | Set to `true` to return `null` while the list is loading | `id` | Optional | `string` | | The record identifier. If not provided, it will be deduced from the URL +| `loading` | Optional | `ReactNode` | | The component to render while checking for authentication and permissions +| `offline` | Optional | `ReactNode` | | The component to render when there is no connectivity and the record isn't in the cache | `queryOptions` | Optional | `object` | | The options to pass to the `useQuery` hook | `resource` | Optional | `string` | | The resource name, e.g. `posts` @@ -107,38 +108,13 @@ const BookShow = () => ( ``` {% endraw %} -## `render` - -Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ShowContext` as argument. - -{% raw %} -```jsx -import { ShowBase, TextField, DateField, ReferenceField, WithRecord } from 'react-admin'; - -const BookShow = () => ( - { - if (isPending) { - return

Loading...

; - } - - if (error) { - return ( -

- {error.message} -

- ); - } - return

{record.title}

; - }}/> -); -``` -{% endraw %} - ## `disableAuthentication` By default, the `` component will automatically redirect the user to the login page if the user is not authenticated. If you want to disable this behavior and allow anonymous access to a show page, set the `disableAuthentication` prop to `true`. ```jsx +import { ShowBase } from 'react-admin'; + const PostShow = () => ( ... @@ -151,6 +127,8 @@ const PostShow = () => ( By default, `` deduces the identifier of the record to show from the URL path. So under the `/posts/123/show` path, the `id` prop will be `123`. You may want to force a different identifier. In this case, pass a custom `id` prop. ```jsx +import { ShowBase } from 'react-admin'; + export const PostShow = () => ( ... @@ -160,6 +138,49 @@ export const PostShow = () => ( **Tip**: Pass both a custom `id` and a custom `resource` prop to use `` independently of the current URL. This even allows you to use more than one `` component in the same page. +## `loading` + +By default, `` renders nothing while checking for authentication and permissions. You can provide your own component via the `loading` prop: + +```jsx +import { ShowBase } from 'react-admin'; + +export const PostShow = () => ( + Checking for permissions...

}> + ... +
+); +``` + +## `offline` + +By default, `` renders nothing when there is no connectivity and the record hasn't been cached yet. You can provide your own component via the `offline` prop: + +```jsx +import { ShowBase } from 'react-admin'; + +export const PostShow = () => ( + No network. Could not load the post.

}> + ... +
+); +``` + +**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 { ShowBase, IsOffline } from 'react-admin'; + +export const PostShow = () => ( + No network. Could not load the post.

}> + + No network. The post data may be outdated. + + ... +
+); +``` + ## `queryOptions` `` accepts a `queryOptions` prop to pass options to the react-query client. @@ -205,11 +226,40 @@ The default `onError` function is: } ``` +## `render` + +Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ShowContext` as argument. + +{% raw %} +```jsx +import { ShowBase, TextField, DateField, ReferenceField, WithRecord } from 'react-admin'; + +const BookShow = () => ( + { + if (isPending) { + return

Loading...

; + } + + if (error) { + return ( +

+ {error.message} +

+ ); + } + return

{record.title}

; + }}/> +); +``` +{% endraw %} + ## `resource` By default, `` operates on the current `ResourceContext` (defined at the routing level), so under the `/posts/1/show` path, the `resource` prop will be `posts`. You may want to force a different resource. In this case, pass a custom `resource` prop, and it will override the `ResourceContext` value. ```jsx +import { ShowBase } from 'react-admin'; + export const UsersShow = () => ( ... diff --git a/packages/ra-core/src/controller/show/ShowBase.spec.tsx b/packages/ra-core/src/controller/show/ShowBase.spec.tsx index 7429cf5cfc9..f77709bedb6 100644 --- a/packages/ra-core/src/controller/show/ShowBase.spec.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.spec.tsx @@ -7,6 +7,7 @@ import { AccessControl, DefaultTitle, NoAuthProvider, + Offline, WithAuthProviderNoAccessControl, WithRenderProp, } from './ShowBase.stories'; @@ -118,4 +119,16 @@ describe('ShowBase', () => { expect(dataProvider.getOne).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('Hello'); + expect( + screen.queryByText('You are offline, cannot load data') + ).toBeNull(); + rerender(); + await screen.findByText('You are offline, the data may be outdated'); + }); }); diff --git a/packages/ra-core/src/controller/show/ShowBase.stories.tsx b/packages/ra-core/src/controller/show/ShowBase.stories.tsx index adc42bc74e2..81b72f51c45 100644 --- a/packages/ra-core/src/controller/show/ShowBase.stories.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.stories.tsx @@ -2,19 +2,21 @@ import * as React from 'react'; import englishMessages from 'ra-language-english'; import frenchMessages from 'ra-language-french'; import polyglotI18nProvider from 'ra-i18n-polyglot'; +import fakeRestDataProvider from 'ra-data-fakerest'; import { AuthProvider, CoreAdminContext, ShowBase, ShowBaseProps, DataProvider, - testDataProvider, - useRecordContext, mergeTranslations, I18nProvider, useShowContext, useLocaleState, + IsOffline, + WithRecord, } from '../..'; +import { onlineManager } from '@tanstack/react-query'; export default { title: 'ra-core/controller/ShowBase', @@ -162,11 +164,50 @@ export const WithRenderProp = ({ ); -const defaultDataProvider = testDataProvider({ - getOne: () => - // @ts-ignore - Promise.resolve({ data: { id: 12, test: 'Hello', title: 'Hello' } }), -}); +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

} + > + +
+
+ ); +}; + +Offline.args = { + isOnline: true, +}; + +Offline.argTypes = { + isOnline: { + control: { type: 'boolean' }, + }, +}; + +const defaultDataProvider = fakeRestDataProvider( + { + posts: [ + { id: 12, test: 'Hello', title: 'Hello' }, + { id: 13, test: 'World', title: 'World' }, + ], + }, + process.env.NODE_ENV !== 'test', + process.env.NODE_ENV !== 'test' ? 300 : 0 +); const defaultProps = { id: 12, @@ -174,9 +215,21 @@ const defaultProps = { }; const Child = () => { - const record = useRecordContext(); + return

{record?.test}

} />; +}; - return

{record?.test}

; +const OfflineChild = () => { + return ( + <> +

Use the story controls to simulate offline mode:

+ +

+ You are offline, the data may be outdated +

+
+

{record?.test}

} /> + + ); }; const Title = () => { diff --git a/packages/ra-core/src/controller/show/ShowBase.tsx b/packages/ra-core/src/controller/show/ShowBase.tsx index 264a07b7866..91deecfbb88 100644 --- a/packages/ra-core/src/controller/show/ShowBase.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.tsx @@ -41,8 +41,10 @@ import { useIsAuthPending } from '../../auth'; */ export const ShowBase = ({ children, + disableAuthentication, + loading, + offline, render, - loading = null, ...props }: ShowBaseProps) => { const controllerProps = useShowController(props); @@ -52,21 +54,34 @@ export const ShowBase = ({ action: 'show', }); - if (isAuthPending && !props.disableAuthentication) { - return loading; - } - if (!render && !children) { throw new Error( ' requires either a `render` prop or `children` prop' ); } + const { isPaused, record } = controllerProps; + + const shouldRenderLoading = + isAuthPending && + !disableAuthentication && + loading !== false && + loading !== undefined; + + const shouldRenderOffline = + isPaused && !record && offline !== false && offline !== undefined; + 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} + {shouldRenderLoading + ? loading + : shouldRenderOffline + ? offline + : render + ? render(controllerProps) + : children} ); @@ -77,4 +92,5 @@ export interface ShowBaseProps children?: React.ReactNode; render?: (props: ShowControllerResult) => React.ReactNode; loading?: React.ReactNode; + offline?: React.ReactNode; } diff --git a/packages/ra-core/src/controller/show/useShowController.ts b/packages/ra-core/src/controller/show/useShowController.ts index 4f523a8b126..c45ae8d4c52 100644 --- a/packages/ra-core/src/controller/show/useShowController.ts +++ b/packages/ra-core/src/controller/show/useShowController.ts @@ -97,7 +97,9 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, + isPlaceholderData, refetch, } = useGetOne( resource, @@ -159,7 +161,9 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, + isPlaceholderData, record, refetch, resource, @@ -180,6 +184,8 @@ export interface ShowControllerBaseResult { defaultTitle?: string; isFetching: boolean; isLoading: boolean; + isPaused: boolean; + isPlaceholderData: boolean; resource: string; record?: RecordType; refetch: UseGetOneHookValue['refetch']; diff --git a/packages/ra-core/src/core/IsOffline.spec.tsx b/packages/ra-core/src/core/IsOffline.spec.tsx new file mode 100644 index 00000000000..29695e98617 --- /dev/null +++ b/packages/ra-core/src/core/IsOffline.spec.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Basic } from './IsOffline.stories'; + +describe('', () => { + it('should render children when offline', async () => { + const { rerender } = render(); + await screen.findByText('You are offline, the data may be outdated'); + rerender(); + expect( + screen.queryByText('You are offline, the data may be outdated') + ).toBeNull(); + }); +}); diff --git a/packages/ra-core/src/core/IsOffline.stories.tsx b/packages/ra-core/src/core/IsOffline.stories.tsx new file mode 100644 index 00000000000..7f6c3c5ca67 --- /dev/null +++ b/packages/ra-core/src/core/IsOffline.stories.tsx @@ -0,0 +1,33 @@ +import { onlineManager } from '@tanstack/react-query'; +import React from 'react'; +import { IsOffline } from './IsOffline'; + +export default { + title: 'ra-core/core/IsOffline', +}; + +export const Basic = ({ isOnline = true }: { isOnline?: boolean }) => { + React.useEffect(() => { + onlineManager.setOnline(isOnline); + }, [isOnline]); + return ( + <> +

Use the story controls to simulate offline mode:

+ +

+ You are offline, the data may be outdated +

+
+ + ); +}; + +Basic.args = { + isOnline: true, +}; + +Basic.argTypes = { + isOnline: { + control: { type: 'boolean' }, + }, +}; diff --git a/packages/ra-core/src/core/IsOffline.tsx b/packages/ra-core/src/core/IsOffline.tsx new file mode 100644 index 00000000000..5e80c404b6d --- /dev/null +++ b/packages/ra-core/src/core/IsOffline.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { useIsOffline } from './useIsOffline'; + +export const IsOffline = ({ children }: { children: React.ReactNode }) => { + const isOffline = useIsOffline(); + return isOffline ? children : null; +}; diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts index fb7b21aa759..91c75d1213f 100644 --- a/packages/ra-core/src/core/index.ts +++ b/packages/ra-core/src/core/index.ts @@ -5,6 +5,7 @@ export * from './CoreAdminUI'; export * from './CustomRoutes'; export * from './DefaultTitleContext'; export * from './HasDashboardContext'; +export * from './IsOffline'; export * from './NavigateToFirstResource'; export * from './OptionalResourceContextProvider'; export * from './Resource'; @@ -15,6 +16,7 @@ export * from './SourceContext'; export * from './useFirstResourceWithListAccess'; export * from './useGetResourceLabel'; export * from './useGetRecordRepresentation'; +export * from './useIsOffline'; export * from './useResourceDefinitionContext'; export * from './useResourceContext'; export * from './useResourceDefinition'; diff --git a/packages/ra-core/src/core/useIsOffline.ts b/packages/ra-core/src/core/useIsOffline.ts new file mode 100644 index 00000000000..77c11eabb8d --- /dev/null +++ b/packages/ra-core/src/core/useIsOffline.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { onlineManager } from '@tanstack/react-query'; + +/** + * Hook to determine if the application is offline. + * It uses the onlineManager from react-query to check the online status. + * It returns true if the application is offline, false otherwise. + * @returns {boolean} - True if offline, false if online. + */ +export const useIsOffline = () => { + const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); + + React.useEffect(() => { + const handleChange = () => { + setIsOnline(onlineManager.isOnline()); + }; + return onlineManager.subscribe(handleChange); + }, []); + + return !isOnline; +}; diff --git a/packages/ra-core/src/i18n/TranslationMessages.ts b/packages/ra-core/src/i18n/TranslationMessages.ts index 085bc7d61a1..9ea8666dd37 100644 --- a/packages/ra-core/src/i18n/TranslationMessages.ts +++ b/packages/ra-core/src/i18n/TranslationMessages.ts @@ -168,6 +168,7 @@ export interface TranslationMessages extends StringMap { logged_out: string; not_authorized: string; application_update_available: string; + offline: string; }; validation: { [key: string]: StringMap | string; diff --git a/packages/ra-core/src/i18n/useResourceTranslation.ts b/packages/ra-core/src/i18n/useResourceTranslation.ts index 0c0b9a271f2..753470e0689 100644 --- a/packages/ra-core/src/i18n/useResourceTranslation.ts +++ b/packages/ra-core/src/i18n/useResourceTranslation.ts @@ -13,17 +13,18 @@ export const useResourceTranslation = ( } return translate(userText, { _: userText, ...options }); } + if (!resourceI18nKey) { + return translate(baseI18nKey, options); + } - const translatedText = translate(resourceI18nKey, { + return translate(resourceI18nKey, { ...options, _: translate(baseI18nKey, options), }); - - return translatedText; }; export interface UseResourceTranslationOptions { - resourceI18nKey: string; + resourceI18nKey?: string; baseI18nKey: string; userText?: ReactNode; options?: Record; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index d9435902eae..869bd8665e3 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -172,6 +172,7 @@ const englishMessages: TranslationMessages = { logged_out: 'Your session has ended, please reconnect.', not_authorized: "You're not authorized to access this resource.", application_update_available: 'A new version is available.', + offline: 'No connectivity. Could not fetch data.', }, validation: { required: 'Required', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index a42a2999aa2..3929b48e397 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -180,6 +180,7 @@ const frenchMessages: TranslationMessages = { not_authorized: "Vous n'êtes pas autorisé(e) à accéder à cette ressource.", application_update_available: 'Une mise à jour est disponible.', + offline: 'Pas de connexion. Impossible de charger les données.', }, validation: { required: 'Ce champ est requis', diff --git a/packages/ra-ui-materialui/src/Offline.spec.tsx b/packages/ra-ui-materialui/src/Offline.spec.tsx new file mode 100644 index 00000000000..f6ae349a9d8 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.spec.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18n, I18nResourceSpecific } from './Offline.stories'; + +describe('', () => { + it('should render the default message', async () => { + render(); + await screen.findByText('No connectivity. Could not fetch data.'); + }); + it('should render the resource specific message', async () => { + render(); + await screen.findByText('No connectivity. Could not fetch posts.'); + }); +}); diff --git a/packages/ra-ui-materialui/src/Offline.stories.tsx b/packages/ra-ui-materialui/src/Offline.stories.tsx new file mode 100644 index 00000000000..a04dd711e84 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.stories.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { mergeTranslations, Resource } from 'ra-core'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; +import { Paper } from '@mui/material'; +import { + bwDarkTheme, + bwLightTheme, + defaultDarkTheme, + defaultLightTheme, +} from './theme'; +import { AdminContext } from './AdminContext'; +import { Offline } from './Offline'; + +export default { title: 'ra-ui-materialui/Offline' }; + +export const Standard = ({ theme }) => ( + + + + + } + /> + +); + +Standard.args = { + theme: 'default-light', +}; + +Standard.argTypes = { + theme: { + control: { + type: 'select', + }, + options: ['default-light', 'default-dark', 'bw-light', 'bw-dark'], + mapping: { + 'default-light': defaultLightTheme, + 'default-dark': defaultDarkTheme, + 'bw-light': bwLightTheme, + 'bw-dark': bwDarkTheme, + }, + }, +}; + +export const Inline = ({ theme }) => ( + + + + + } + /> + +); + +Inline.args = { + theme: 'default-light', +}; + +Inline.argTypes = { + theme: { + control: { + type: 'select', + }, + options: ['default-light', 'default-dark', 'bw-light', 'bw-dark'], + mapping: { + 'default-light': defaultLightTheme, + 'default-dark': defaultDarkTheme, + 'bw-light': bwLightTheme, + 'bw-dark': bwDarkTheme, + }, + }, +}; + +export const I18n = () => ( + englishMessages)}> + + + + } + /> + +); + +export const I18nResourceSpecific = () => ( + + mergeTranslations(englishMessages, { + resources: { + posts: { + notification: { + offline: 'No connectivity. Could not fetch posts.', + }, + }, + }, + }) + )} + > + + + + } + /> + +); diff --git a/packages/ra-ui-materialui/src/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx new file mode 100644 index 00000000000..abc9b072047 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { + Alert, + AlertProps, + ComponentsOverrides, + styled, + Typography, +} from '@mui/material'; +import { + useGetResourceLabel, + useResourceContext, + useResourceTranslation, +} from 'ra-core'; +import clsx from 'clsx'; + +export const Offline = (props: Offline) => { + const { icon, message: messageProp, variant = 'standard', ...rest } = props; + const resource = useResourceContext(props); + const getResourceLabel = useGetResourceLabel(); + + const message = useResourceTranslation({ + baseI18nKey: 'ra.notification.offline', + resourceI18nKey: resource + ? `resources.${resource}.notification.offline` + : undefined, + userText: messageProp, + options: { + name: resource ? getResourceLabel(resource, 0) : undefined, + _: 'No connectivity. Could not fetch data.', + }, + }); + + return ( + + {message} + + ); +}; + +export interface Offline extends Omit { + resource?: string; + message?: string; + variant?: AlertProps['variant'] | 'inline'; +} + +const PREFIX = 'RaOffline'; +export const OfflineClasses = { + root: `${PREFIX}-root`, + inline: `${PREFIX}-inline`, +}; + +const Root = styled(Alert, { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(() => ({ + [`&.${OfflineClasses.inline}`]: { + border: 'none', + display: 'inline-flex', + padding: 0, + margin: 0, + }, +})); + +declare module '@mui/material/styles' { + interface ComponentNameToClassKey { + [PREFIX]: 'root'; + } + + interface ComponentsPropsList { + [PREFIX]: Partial; + } + + interface Components { + [PREFIX]?: { + defaultProps?: ComponentsPropsList[typeof PREFIX]; + styleOverrides?: ComponentsOverrides< + Omit + >[typeof PREFIX]; + }; + } +} diff --git a/packages/ra-ui-materialui/src/detail/Show.spec.tsx b/packages/ra-ui-materialui/src/detail/Show.spec.tsx index 88db2e52036..e0d51bf66f6 100644 --- a/packages/ra-ui-materialui/src/detail/Show.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.spec.tsx @@ -26,8 +26,10 @@ import { TitleElement, Themed, WithRenderProp, + Offline, } from './Show.stories'; import { Show } from './Show'; +import { Alert } from '@mui/material'; describe('', () => { beforeEach(async () => { @@ -226,4 +228,29 @@ describe('', () => { await screen.findByText('Foo lorem'); }); }); + 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'); + expect( + screen.queryByText('No connectivity. Could not fetch data.') + ).toBeNull(); + rerender(); + await screen.findByText('You are offline, the data may be outdated'); + }); + 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'); + expect(screen.queryByText('You are offline!')).toBeNull(); + rerender(} />); + await screen.findByText('You are offline, the data may be outdated'); + }); }); diff --git a/packages/ra-ui-materialui/src/detail/Show.stories.tsx b/packages/ra-ui-materialui/src/detail/Show.stories.tsx index 3fdee242f49..0826f766804 100644 --- a/packages/ra-ui-materialui/src/detail/Show.stories.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.stories.tsx @@ -1,15 +1,21 @@ import * as React from 'react'; import { Admin } from 'react-admin'; -import { Resource, useRecordContext, TestMemoryRouter } from 'ra-core'; -import { Box, Card, Stack, ThemeOptions } from '@mui/material'; +import { + Resource, + useRecordContext, + TestMemoryRouter, + IsOffline, +} from 'ra-core'; +import { Alert, Box, Card, Stack, ThemeOptions } from '@mui/material'; +import { deepmerge } from '@mui/utils'; +import { onlineManager } from '@tanstack/react-query'; import { TextField } from '../field'; import { Labeled } from '../Labeled'; import { SimpleShowLayout } from './SimpleShowLayout'; import { EditButton } from '../button'; import TopToolbar from '../layout/TopToolbar'; -import { Show } from './Show'; -import { deepmerge } from '@mui/utils'; +import { Show, ShowProps } from './Show'; import { defaultLightTheme } from '../theme'; export default { title: 'ra-ui-materialui/detail/Show' }; @@ -299,3 +305,67 @@ export const WithRenderProp = () => ( ); + +export const Offline = ({ + isOnline = true, + offline, +}: { + isOnline?: boolean; + offline?: React.ReactNode; +}) => { + React.useEffect(() => { + onlineManager.setOnline(isOnline); + }, [isOnline]); + return ( + + + } + /> + + + ); +}; + +const BookShowOffline = (props: ShowProps) => { + return ( + + + + You are offline, the data may be outdated + + + + + + + + + + ); +}; + +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/detail/Show.tsx b/packages/ra-ui-materialui/src/detail/Show.tsx index 9a97860242c..717c586ce1f 100644 --- a/packages/ra-ui-materialui/src/detail/Show.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.tsx @@ -90,6 +90,8 @@ export const Show = ( queryOptions={queryOptions} resource={resource} loading={loading} + // Disable offline support from ShowBase as it is handled by ShowView to keep the ShowView container + offline={false} >
diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 5028186e685..1554425676a 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ReactElement, ElementType, ReactNode } from 'react'; +import type { ElementType, ReactNode } from 'react'; import { Card, type ComponentsOverrides, @@ -16,8 +16,10 @@ import { import { ShowActions } from './ShowActions'; import { Title } from '../layout'; import { ShowProps } from './Show'; +import { Offline } from '../Offline'; const defaultActions = ; +const defaultOffline = ; export const ShowView = (props: ShowViewProps) => { const { @@ -28,20 +30,32 @@ export const ShowView = (props: ShowViewProps) => { className, component: Content = Card, emptyWhileLoading = false, + offline = defaultOffline, title, ...rest } = props; const showContext = useShowContext(); - const { resource, defaultTitle, record } = showContext; + const { resource, defaultTitle, isPaused, record } = showContext; const { hasEdit } = useResourceDefinition(); const finalActions = typeof actions === 'undefined' && hasEdit ? defaultActions : actions; + if (!record && offline !== false && isPaused) { + return ( + +
+ {offline} + {aside} +
+
+ ); + } if (!record && emptyWhileLoading) { return null; } + return ( {title !== false && ( @@ -68,11 +82,12 @@ export const ShowView = (props: ShowViewProps) => { export interface ShowViewProps extends Omit, 'id' | 'title'> { - actions?: ReactElement | false; - aside?: ReactElement; + actions?: ReactNode | false; + aside?: ReactNode; component?: ElementType; emptyWhileLoading?: boolean; - title?: string | ReactElement | false; + offline?: ReactNode; + title?: ReactNode; sx?: SxProps; render?: (showContext: ShowControllerResult) => ReactNode; } diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 6ef5bfa3d89..811508dd4e3 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -9,6 +9,7 @@ export * from './Labeled'; export * from './layout'; export * from './Link'; export * from './list'; +export * from './Offline'; export * from './preferences'; export * from './AdminUI'; export * from './AdminContext'; diff --git a/packages/ra-ui-materialui/src/layout/Title.tsx b/packages/ra-ui-materialui/src/layout/Title.tsx index 057d9849a1e..878abd1529c 100644 --- a/packages/ra-ui-materialui/src/layout/Title.tsx +++ b/packages/ra-ui-materialui/src/layout/Title.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useEffect, useState, ReactElement } from 'react'; +import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { RaRecord, TitleComponent, warning } from 'ra-core'; @@ -50,6 +50,6 @@ export interface TitleProps { className?: string; defaultTitle?: TitleComponent; record?: Partial; - title?: string | ReactElement; + title?: React.ReactNode; preferenceKey?: string | false; }