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;
}