From 04723adeea3915acbaa5831e3e22231d7de6bbca Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:12:52 +0200 Subject: [PATCH 01/15] Introduce `useIsOffline` hook and `` component --- packages/ra-core/src/core/IsOffline.spec.tsx | 14 ++++++++ .../ra-core/src/core/IsOffline.stories.tsx | 33 +++++++++++++++++++ packages/ra-core/src/core/IsOffline.tsx | 7 ++++ packages/ra-core/src/core/index.ts | 2 ++ packages/ra-core/src/core/useIsOffline.ts | 21 ++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 packages/ra-core/src/core/IsOffline.spec.tsx create mode 100644 packages/ra-core/src/core/IsOffline.stories.tsx create mode 100644 packages/ra-core/src/core/IsOffline.tsx create mode 100644 packages/ra-core/src/core/useIsOffline.ts 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..3e0c3dca7c2 --- /dev/null +++ b/packages/ra-core/src/core/IsOffline.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { useIsOffine } from './useIsOffline'; + +export const IsOffline = ({ children }: { children: React.ReactNode }) => { + const isOffline = useIsOffine(); + 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..6e54c89ef1d --- /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 useIsOffine = () => { + const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); + + React.useEffect(() => { + const handleChange = () => { + setIsOnline(onlineManager.isOnline()); + }; + return onlineManager.subscribe(handleChange); + }, []); + + return !isOnline; +}; From 9cb47ecfecae0082c0950a6b4b1bd75b27089d8d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:13:01 +0200 Subject: [PATCH 02/15] Add offline support to `` --- .../src/controller/show/ShowBase.spec.tsx | 13 +++ .../src/controller/show/ShowBase.stories.tsx | 80 +++++++++++++++---- .../ra-core/src/controller/show/ShowBase.tsx | 30 +++++-- .../src/controller/show/useShowController.ts | 6 ++ 4 files changed, 108 insertions(+), 21 deletions(-) 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..45a887686b8 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', @@ -68,7 +70,7 @@ export const DefaultTitle = ({ translations?: 'default' | 'resource specific'; }) => ( @@ -88,7 +90,7 @@ DefaultTitle.argTypes = { }; export const NoAuthProvider = ({ - dataProvider = defaultDataProvider, + dataProvider = defaultDataProvider(), ...props }: { dataProvider?: DataProvider; @@ -107,7 +109,7 @@ export const WithAuthProviderNoAccessControl = ({ checkError: () => Promise.resolve(), checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), }, - dataProvider = defaultDataProvider, + dataProvider = defaultDataProvider(), }: { authProvider?: AuthProvider; dataProvider?: DataProvider; @@ -130,7 +132,7 @@ export const AccessControl = ({ checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), canAccess: () => new Promise(resolve => setTimeout(resolve, 300, true)), }, - dataProvider = defaultDataProvider, + dataProvider = defaultDataProvider(), }: { authProvider?: AuthProvider; dataProvider?: DataProvider; @@ -146,7 +148,7 @@ export const AccessControl = ({ ); export const WithRenderProp = ({ - dataProvider = defaultDataProvider, + dataProvider = defaultDataProvider(), ...props }: { dataProvider?: DataProvider; @@ -162,11 +164,51 @@ 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 = (delay = 300) => + fakeRestDataProvider( + { + posts: [ + { id: 12, test: 'Hello', title: 'Hello' }, + { id: 13, test: 'World', title: 'World' }, + ], + }, + process.env.NODE_ENV !== 'test', + process.env.NODE_ENV !== 'test' ? delay : 0 + ); const defaultProps = { id: 12, @@ -174,9 +216,17 @@ const defaultProps = { }; const Child = () => { - const record = useRecordContext(); - - return

{record?.test}

; + 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..019f4346333 100644 --- a/packages/ra-core/src/controller/show/ShowBase.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.tsx @@ -42,7 +42,8 @@ import { useIsAuthPending } from '../../auth'; export const ShowBase = ({ children, render, - loading = null, + loading, + offline, ...props }: ShowBaseProps) => { const controllerProps = useShowController(props); @@ -52,21 +53,37 @@ 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; + 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} + {(() => { + if ( + isAuthPending && + !props.disableAuthentication && + loading !== false && + loading !== undefined + ) { + return loading; + } + if ( + isPaused && + !record && + offline !== false && + offline !== undefined + ) { + return offline; + } + return render ? render(controllerProps) : children; + })()} ); @@ -77,4 +94,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']; From 72750f7f394b5a8e3724efb26e2b94af7e258102 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:49:53 +0200 Subject: [PATCH 03/15] Introduce `` component --- .../ra-core/src/i18n/TranslationMessages.ts | 1 + packages/ra-language-english/src/index.ts | 1 + packages/ra-language-french/src/index.ts | 1 + .../ra-ui-materialui/src/Offline.spec.tsx | 14 +++ .../ra-ui-materialui/src/Offline.stories.tsx | 117 ++++++++++++++++++ packages/ra-ui-materialui/src/Offline.tsx | 91 ++++++++++++++ packages/ra-ui-materialui/src/index.ts | 1 + 7 files changed, 226 insertions(+) create mode 100644 packages/ra-ui-materialui/src/Offline.spec.tsx create mode 100644 packages/ra-ui-materialui/src/Offline.stories.tsx create mode 100644 packages/ra-ui-materialui/src/Offline.tsx 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-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..797f4a89e68 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -0,0 +1,91 @@ +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(); + if (!resource) { + throw new Error( + ' must be used inside a component or provided a resource prop' + ); + } + const message = useResourceTranslation({ + baseI18nKey: 'ra.notification.offline', + resourceI18nKey: `resources.${resource}.notification.offline`, + userText: messageProp, + options: { + name: getResourceLabel(resource, 0), + _: '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/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'; From 80a1878663e61a2d4d0a6eae813792c2406d8f8e Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:50:12 +0200 Subject: [PATCH 04/15] Add offline support to `` --- .../ra-ui-materialui/src/detail/Show.spec.tsx | 27 +++++++ .../src/detail/Show.stories.tsx | 78 ++++++++++++++++++- packages/ra-ui-materialui/src/detail/Show.tsx | 2 + .../ra-ui-materialui/src/detail/ShowView.tsx | 25 ++++-- .../ra-ui-materialui/src/layout/Title.tsx | 4 +- 5 files changed, 125 insertions(+), 11 deletions(-) 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..9463824f5a4 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: false, +}; + +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/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; } From c31dde57c9def753dea90a3ce3900016ec10e1ae Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:10:20 +0200 Subject: [PATCH 05/15] Update `` documentation --- docs/ShowBase.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 7e68a762962..c7e4d0600cd 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` @@ -139,6 +140,8 @@ const BookShow = () => ( 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 'ra-core'; + const PostShow = () => ( ... @@ -151,6 +154,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 'ra-core'; + export const PostShow = () => ( ... @@ -160,6 +165,48 @@ 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 'ra-core'; + +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 'ra-core'; + +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 'ra-core'; + +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. @@ -210,6 +257,8 @@ The default `onError` function is: 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 'ra-core'; + export const UsersShow = () => ( ... From 7ccd7ef77031e42cb51701dfc8ca5e33bdfd6a9d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:08:00 +0200 Subject: [PATCH 06/15] Update `` documentation --- docs/Show.md | 41 ++++++++++++++++++++++++++++++++++++++++- docs/ShowBase.md | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/Show.md b/docs/Show.md index 470a50fb81b..ab4ceabe2f8 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,35 @@ 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 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 { 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'; + +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. @@ -372,6 +409,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 c7e4d0600cd..7649675ee74 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -203,6 +203,7 @@ export const PostShow = () => ( No network. The post data may be outdated. + ...
); ``` From cc16194e3a8381617d8985a12ccbdc830b2e8c94 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:13:03 +0200 Subject: [PATCH 07/15] Simplify ShowBase story --- .../src/controller/show/ShowBase.stories.tsx | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/ra-core/src/controller/show/ShowBase.stories.tsx b/packages/ra-core/src/controller/show/ShowBase.stories.tsx index 45a887686b8..54d8a4ad9c5 100644 --- a/packages/ra-core/src/controller/show/ShowBase.stories.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.stories.tsx @@ -70,7 +70,7 @@ export const DefaultTitle = ({ translations?: 'default' | 'resource specific'; }) => ( @@ -90,7 +90,7 @@ DefaultTitle.argTypes = { }; export const NoAuthProvider = ({ - dataProvider = defaultDataProvider(), + dataProvider = defaultDataProvider, ...props }: { dataProvider?: DataProvider; @@ -109,7 +109,7 @@ export const WithAuthProviderNoAccessControl = ({ checkError: () => Promise.resolve(), checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), }, - dataProvider = defaultDataProvider(), + dataProvider = defaultDataProvider, }: { authProvider?: AuthProvider; dataProvider?: DataProvider; @@ -132,7 +132,7 @@ export const AccessControl = ({ checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), canAccess: () => new Promise(resolve => setTimeout(resolve, 300, true)), }, - dataProvider = defaultDataProvider(), + dataProvider = defaultDataProvider, }: { authProvider?: AuthProvider; dataProvider?: DataProvider; @@ -148,7 +148,7 @@ export const AccessControl = ({ ); export const WithRenderProp = ({ - dataProvider = defaultDataProvider(), + dataProvider = defaultDataProvider, ...props }: { dataProvider?: DataProvider; @@ -165,7 +165,7 @@ export const WithRenderProp = ({ ); export const Offline = ({ - dataProvider = defaultDataProvider(), + dataProvider = defaultDataProvider, isOnline = true, ...props }: { @@ -198,17 +198,16 @@ Offline.argTypes = { }, }; -const defaultDataProvider = (delay = 300) => - fakeRestDataProvider( - { - posts: [ - { id: 12, test: 'Hello', title: 'Hello' }, - { id: 13, test: 'World', title: 'World' }, - ], - }, - process.env.NODE_ENV !== 'test', - process.env.NODE_ENV !== 'test' ? delay : 0 - ); +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, From cc2ec92a6be1aa3d5f0c914f61f4eebca663c54f Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:37:27 +0200 Subject: [PATCH 08/15] Only show offline instructions in offline story --- packages/ra-core/src/controller/show/ShowBase.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/controller/show/ShowBase.stories.tsx b/packages/ra-core/src/controller/show/ShowBase.stories.tsx index 54d8a4ad9c5..81b72f51c45 100644 --- a/packages/ra-core/src/controller/show/ShowBase.stories.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.stories.tsx @@ -182,7 +182,7 @@ export const Offline = ({ {...props} offline={

You are offline, cannot load data

} > - +
); @@ -215,6 +215,10 @@ const defaultProps = { }; const Child = () => { + return

{record?.test}

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

Use the story controls to simulate offline mode:

From 15223cfd3d81b8b5a031e7132fb2cf30deee4478 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:04:59 +0200 Subject: [PATCH 09/15] reorder props sections in ShowBase documentation --- docs/ShowBase.md | 54 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 7649675ee74..ee39a80f98a 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -108,33 +108,6 @@ 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`. @@ -253,6 +226,33 @@ 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. From 38af6380c2be12b4c80994756a8f6dc7c203db30 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:24:12 +0200 Subject: [PATCH 10/15] Fix Show Offline story --- packages/ra-ui-materialui/src/detail/Show.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/detail/Show.stories.tsx b/packages/ra-ui-materialui/src/detail/Show.stories.tsx index 9463824f5a4..0826f766804 100644 --- a/packages/ra-ui-materialui/src/detail/Show.stories.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.stories.tsx @@ -352,7 +352,7 @@ const CustomOffline = () => { Offline.args = { isOnline: true, - offline: false, + offline: 'default', }; Offline.argTypes = { From 69c262f4afabd058eff3581fc6e04b66d95a9b9d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:57:04 +0200 Subject: [PATCH 11/15] Refactor rendered content in ShowBase --- .../ra-core/src/controller/show/ShowBase.tsx | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/ra-core/src/controller/show/ShowBase.tsx b/packages/ra-core/src/controller/show/ShowBase.tsx index 019f4346333..91deecfbb88 100644 --- a/packages/ra-core/src/controller/show/ShowBase.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.tsx @@ -41,9 +41,10 @@ import { useIsAuthPending } from '../../auth'; */ export const ShowBase = ({ children, - render, + disableAuthentication, loading, offline, + render, ...props }: ShowBaseProps) => { const controllerProps = useShowController(props); @@ -61,29 +62,26 @@ export const ShowBase = ({ 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 - {(() => { - if ( - isAuthPending && - !props.disableAuthentication && - loading !== false && - loading !== undefined - ) { - return loading; - } - if ( - isPaused && - !record && - offline !== false && - offline !== undefined - ) { - return offline; - } - return render ? render(controllerProps) : children; - })()} + {shouldRenderLoading + ? loading + : shouldRenderOffline + ? offline + : render + ? render(controllerProps) + : children} ); From 60b6f9de112ea6870db1a90d2f982bf250a170b2 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:43:23 +0200 Subject: [PATCH 12/15] Rename useIsOffine to useIsOffline --- packages/ra-core/src/core/IsOffline.tsx | 4 ++-- packages/ra-core/src/core/useIsOffline.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/core/IsOffline.tsx b/packages/ra-core/src/core/IsOffline.tsx index 3e0c3dca7c2..5e80c404b6d 100644 --- a/packages/ra-core/src/core/IsOffline.tsx +++ b/packages/ra-core/src/core/IsOffline.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { useIsOffine } from './useIsOffline'; +import { useIsOffline } from './useIsOffline'; export const IsOffline = ({ children }: { children: React.ReactNode }) => { - const isOffline = useIsOffine(); + const isOffline = useIsOffline(); return isOffline ? children : null; }; diff --git a/packages/ra-core/src/core/useIsOffline.ts b/packages/ra-core/src/core/useIsOffline.ts index 6e54c89ef1d..77c11eabb8d 100644 --- a/packages/ra-core/src/core/useIsOffline.ts +++ b/packages/ra-core/src/core/useIsOffline.ts @@ -7,7 +7,7 @@ import { onlineManager } from '@tanstack/react-query'; * It returns true if the application is offline, false otherwise. * @returns {boolean} - True if offline, false if online. */ -export const useIsOffine = () => { +export const useIsOffline = () => { const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); React.useEffect(() => { From c9c12d3af8a2ab4a27aa2d4c37bdf4cc4d11fc85 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:29:06 +0200 Subject: [PATCH 13/15] Update docs/Show.md Co-authored-by: Madeorsk --- docs/Show.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Show.md b/docs/Show.md index ab4ceabe2f8..c325f20492d 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -292,7 +292,7 @@ export const PostShow = () => ( ## `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: +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'; From 129c5e3176fc4a01e32166414dcda279cbe3ea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Wed, 30 Jul 2025 06:50:52 +0200 Subject: [PATCH 14/15] Allow Offline to work outside of a ResourceContext --- packages/ra-core/src/i18n/useResourceTranslation.ts | 9 +++++---- packages/ra-ui-materialui/src/Offline.tsx | 12 +++++------- 2 files changed, 10 insertions(+), 11 deletions(-) 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-ui-materialui/src/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx index 797f4a89e68..abc9b072047 100644 --- a/packages/ra-ui-materialui/src/Offline.tsx +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -17,17 +17,15 @@ export const Offline = (props: Offline) => { const { icon, message: messageProp, variant = 'standard', ...rest } = props; const resource = useResourceContext(props); const getResourceLabel = useGetResourceLabel(); - if (!resource) { - throw new Error( - ' must be used inside a component or provided a resource prop' - ); - } + const message = useResourceTranslation({ baseI18nKey: 'ra.notification.offline', - resourceI18nKey: `resources.${resource}.notification.offline`, + resourceI18nKey: resource + ? `resources.${resource}.notification.offline` + : undefined, userText: messageProp, options: { - name: getResourceLabel(resource, 0), + name: resource ? getResourceLabel(resource, 0) : undefined, _: 'No connectivity. Could not fetch data.', }, }); From e82301b3cff390373a0b050e06adf65346c00824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Wed, 30 Jul 2025 06:51:06 +0200 Subject: [PATCH 15/15] Misc doc fixes --- docs/Show.md | 5 ++++- docs/ShowBase.md | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/Show.md b/docs/Show.md index c325f20492d..5c43dddee4b 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -308,11 +308,14 @@ export const PostShow = () => ( ```jsx import { Show, IsOffline } from 'react-admin'; +import { Alert } from '@mui/material'; export const PostShow = () => ( No network. Could not load the post.

}> - No network. The post data may be outdated. + + You are offline, the data may be outdated + ...
diff --git a/docs/ShowBase.md b/docs/ShowBase.md index ee39a80f98a..c0c84a6e398 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -113,7 +113,7 @@ const BookShow = () => ( 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 'ra-core'; +import { ShowBase } from 'react-admin'; const PostShow = () => ( @@ -127,7 +127,7 @@ 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 'ra-core'; +import { ShowBase } from 'react-admin'; export const PostShow = () => ( @@ -143,7 +143,7 @@ export const PostShow = () => ( By default, `` renders nothing while checking for authentication and permissions. You can provide your own component via the `loading` prop: ```jsx -import { ShowBase } from 'ra-core'; +import { ShowBase } from 'react-admin'; export const PostShow = () => ( Checking for permissions...

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

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

}> @@ -258,7 +258,7 @@ const BookShow = () => ( 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 'ra-core'; +import { ShowBase } from 'react-admin'; export const UsersShow = () => (