diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index 82bd610dbef..1edfdce7dd6 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -79,6 +79,7 @@ It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for perform | `empty` | Optional | `ReactNode` | - | What to render when the field has no value or when the reference is missing | | `label` | Optional | `string | Function` | `resources. [resource]. fields.[source]` | Label to use for the field when rendered in layout components | | `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | +| `offline` | Optional | `ReactNode` | - | What to render when there is no network connectivity when loading the record | | `queryOptions` | Optional | [`UseQuery Options`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | | `sortBy` | Optional | `string | Function` | `source` | Name of the field to use for sorting when used in a Datagrid | @@ -98,6 +99,7 @@ By default, `` renders the `recordRepresentation` of the referen ``` Alternatively, you can use [the `render` prop](#render) to render the referenced record in a custom way. + ## `empty` `` can display a custom message when the referenced record is missing, thanks to the `empty` prop. @@ -172,6 +174,21 @@ You can also use a custom `link` function to get a custom path for the children. /> ``` +## `offline` + +When the user is offline, `` is smart enough to display the referenced record if it was previously fetched. However, if the referenced record has never been fetched before, `` displays an error message explaining that the app has lost network connectivity. + +You can customize this error message by passing a React element or a string to the `offline` prop: + +```jsx +No network, could not fetch data} > + ... + + + ... + +``` + ## `queryOptions` Use the `queryOptions` prop to pass options to [the `dataProvider.getMany()` query](./useGetOne.md#aggregating-getone-calls) that fetches the referenced record. diff --git a/docs/ReferenceFieldBase.md b/docs/ReferenceFieldBase.md index d2c7695092e..a4ead74962b 100644 --- a/docs/ReferenceFieldBase.md +++ b/docs/ReferenceFieldBase.md @@ -68,6 +68,7 @@ It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for perform | `children` | Optional | `ReactNode` | - | React component to render the referenced record. | | `render` | Optional | `(context) => ReactNode` | - | Function that takes the referenceFieldContext and renders the referenced record. | | `empty` | Optional | `ReactNode` | - | What to render when the field has no value or when the reference is missing | +| `offline` | Optional | `ReactNode` | - | What to render when there is no network connectivity when loading the record | | `queryOptions` | Optional | [`UseQuery Options`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | | `sortBy` | Optional | `string | Function` | `source` | Name of the field to use for sorting when used in a Datagrid | @@ -100,35 +101,6 @@ export const MyReferenceField = () => ( ); ``` -## `render` - -Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ReferenceFieldContext` as argument. - -```jsx -export const MyReferenceField = () => ( - { - if (isPending) { - return

Loading...

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

- {error.message} -

- ); - } - return

{referenceRecord.name}

; - }} - /> -); -``` - -The `render` function prop will take priority on `children` props if both are set. - ## `empty` `` can display a custom message when the referenced record is missing, thanks to the `empty` prop. @@ -155,6 +127,21 @@ You can pass either a React element or a string to the `empty` prop: ``` +## `offline` + +When the user is offline, `` is smart enough to display the referenced record if it was previously fetched. However, if the referenced record has never been fetched before, `` displays an error message explaining that the app has lost network connectivity. + +You can customize this error message by passing a React element or a string to the `offline` prop: + +```jsx +No network, could not fetch data} > + ... + + + ... + +``` + ## `queryOptions` Use the `queryOptions` prop to pass options to [the `dataProvider.getMany()` query](./useGetOne.md#aggregating-getone-calls) that fetches the referenced record. @@ -186,6 +173,36 @@ For instance, if the `posts` resource has a `user_id` field, set the `reference` ``` + +## `render` + +Alternatively, you can pass a `render` function prop instead of children. This function will receive the `ReferenceFieldContext` as argument. + +```jsx +export const MyReferenceField = () => ( + { + if (isPending) { + return

Loading...

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

+ {error.message} +

+ ); + } + return

{referenceRecord.name}

; + }} + /> +); +``` + +The `render` function prop will take priority on `children` props if both are set. + ## `sortBy` By default, when used in a ``, and when the user clicks on the column header of a ``, react-admin sorts the list by the field `source`. To specify another field name to sort by, set the `sortBy` prop. diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx index 9ee4ae816b5..98adc484e4c 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import expect from 'expect'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { CoreAdminContext } from '../../core/CoreAdminContext'; import { useResourceContext } from '../../core/useResourceContext'; import { testDataProvider } from '../../dataProvider'; @@ -10,8 +10,10 @@ import { Errored, Loading, Meta, + Offline, WithRenderProp, } from './ReferenceFieldBase.stories'; +import { RecordContextProvider } from '../record'; describe('', () => { beforeAll(() => { @@ -46,20 +48,20 @@ describe('', () => { return
{resource}
; }; const dataProvider = testDataProvider({ - // @ts-ignore - getList: () => + getMany: () => + // @ts-ignore Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), }); render( - - - + + + + + ); - await waitFor(() => { - expect(screen.queryByText('posts')).not.toBeNull(); - }); + await screen.findByText('posts'); }); it('should accept meta in queryOptions', async () => { @@ -70,8 +72,8 @@ describe('', () => { ); const dataProvider = testDataProvider({ getMany, - // @ts-ignore getOne: () => + // @ts-ignore Promise.resolve({ data: { id: 1, @@ -165,4 +167,13 @@ describe('', () => { }); }); }); + + it('should render the offline prop node when offline', async () => { + render(); + fireEvent.click(await screen.findByText('Simulate offline')); + fireEvent.click(await screen.findByText('Toggle Child')); + await screen.findByText('You are offline, cannot load data'); + fireEvent.click(await screen.findByText('Simulate online')); + await screen.findByText('Leo'); + }); }); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx index 168f35c0909..5a789ed1fa6 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { QueryClient } from '@tanstack/react-query'; +import { onlineManager, QueryClient } from '@tanstack/react-query'; import fakeRestDataProvider from 'ra-data-fakerest'; import { CoreAdmin } from '../../core/CoreAdmin'; import { Resource } from '../../core/Resource'; @@ -9,6 +9,7 @@ import { ReferenceFieldBase } from './ReferenceFieldBase'; import { useFieldValue } from '../../util/useFieldValue'; import { useReferenceFieldContext } from './ReferenceFieldContext'; import { DataProvider } from '../../types'; +import { useIsOffline } from '../../core/useIsOffline'; export default { title: 'ra-core/controller/field/ReferenceFieldBase', @@ -395,6 +396,77 @@ export const WithRenderProp = ({ dataProvider = dataProviderWithAuthors }) => ( ); +export const Offline = () => { + return ( + + + + +
+ + + You are offline, cannot load + data +

+ } + > + + + +
+
+
+ + + } + /> +
+
+ ); +}; + +const SimulateOfflineButton = () => { + const isOffline = useIsOffline(); + return ( + + ); +}; + +const RenderChildOnDemand = ({ children }) => { + const [showChild, setShowChild] = React.useState(false); + return ( + <> + + {showChild &&
{children}
} + + ); +}; + const MyReferenceField = (props: { children: React.ReactNode }) => { const context = useReferenceFieldContext(); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx b/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx index 8a43db5087c..3a92f14698a 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldBase.tsx @@ -47,7 +47,7 @@ export const ReferenceFieldBase = < >( props: ReferenceFieldBaseProps ) => { - const { children, render, loading, error, empty = null } = props; + const { children, render, loading, error, empty, offline } = props; const id = useFieldValue(props); const controllerProps = @@ -59,43 +59,48 @@ export const ReferenceFieldBase = < ); } - if (controllerProps.isPending && loading) { - return ( - - {loading} - - ); - } - if (controllerProps.error && error) { - return ( - - - {error} - - - ); - } - if ( - (empty && - // no foreign key value - !id) || - // no reference record - (!controllerProps.error && - !controllerProps.isPending && - !controllerProps.referenceRecord) - ) { - return ( - - {empty} - - ); - } - + const { + error: controllerError, + isPending, + isPaused, + referenceRecord, + } = controllerProps; + const shouldRenderLoading = + id != null && + !isPaused && + isPending && + loading !== false && + loading !== undefined; + const shouldRenderOffline = + isPaused && + !referenceRecord && + offline !== false && + offline !== undefined; + const shouldRenderError = + !!controllerError && error !== false && error !== undefined; + const shouldRenderEmpty = + !isPaused && + (!id || + (!referenceRecord && + !controllerError && + !isPending && + empty !== false && + empty !== undefined)); return ( - - {render ? render(controllerProps) : children} + + {shouldRenderLoading + ? loading + : shouldRenderOffline + ? offline + : shouldRenderError + ? error + : shouldRenderEmpty + ? empty + : render + ? render(controllerProps) + : children} @@ -113,6 +118,7 @@ export interface ReferenceFieldBaseProps< empty?: ReactNode; error?: ReactNode; loading?: ReactNode; + offline?: ReactNode; queryOptions?: Partial< UseQueryOptions & { meta?: any; diff --git a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx index fbba23a4106..7a17987637a 100644 --- a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx +++ b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx @@ -76,47 +76,57 @@ export const useReferenceOneFieldController = < const notify = useNotify(); const { meta, ...otherQueryOptions } = queryOptions; - const { data, error, isFetching, isLoading, isPending, refetch } = - useGetManyReference( - reference, - { - target, - id: get(record, source), - pagination: { page: 1, perPage: 1 }, - sort, - filter, - meta, - }, - { - enabled: !!record, - onError: error => - notify( - typeof error === 'string' - ? error - : (error as Error).message || - 'ra.notification.http_error', - { - type: 'error', - messageArgs: { - _: - typeof error === 'string' - ? error - : (error as Error)?.message - ? (error as Error).message - : undefined, - }, - } - ), - ...otherQueryOptions, - } - ); + const { + data, + error, + isFetching, + isLoading, + isPaused, + isPending, + isPlaceholderData, + refetch, + } = useGetManyReference( + reference, + { + target, + id: get(record, source), + pagination: { page: 1, perPage: 1 }, + sort, + filter, + meta, + }, + { + enabled: !!record, + onError: error => + notify( + typeof error === 'string' + ? error + : (error as Error).message || + 'ra.notification.http_error', + { + type: 'error', + messageArgs: { + _: + typeof error === 'string' + ? error + : (error as Error)?.message + ? (error as Error).message + : undefined, + }, + } + ), + ...otherQueryOptions, + } + ); return { referenceRecord: data ? data[0] : undefined, error, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, refetch, }; }; diff --git a/packages/ra-core/src/controller/useReference.spec.tsx b/packages/ra-core/src/controller/useReference.spec.tsx index e8b991c781c..4c233df6335 100644 --- a/packages/ra-core/src/controller/useReference.spec.tsx +++ b/packages/ra-core/src/controller/useReference.spec.tsx @@ -130,7 +130,9 @@ describe('useReference', () => { referenceRecord: undefined, isFetching: true, isLoading: true, + isPaused: false, isPending: true, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -138,7 +140,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: false, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -170,7 +174,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: true, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -178,7 +184,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: false, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); diff --git a/packages/ra-core/src/controller/useReference.ts b/packages/ra-core/src/controller/useReference.ts index b52526226c9..a6cc7c9a193 100644 --- a/packages/ra-core/src/controller/useReference.ts +++ b/packages/ra-core/src/controller/useReference.ts @@ -21,7 +21,9 @@ export interface UseReferenceResult< ErrorType = Error, > { isLoading: boolean; + isPaused: boolean; isPending: boolean; + isPlaceholderData: boolean; isFetching: boolean; referenceRecord?: RecordType; error?: ErrorType | null; @@ -68,18 +70,28 @@ export const useReference = < ErrorType > => { const { meta, ...otherQueryOptions } = options; - const { data, error, isLoading, isFetching, isPending, refetch } = - useGetManyAggregate( - reference, - { ids: [id], meta }, - otherQueryOptions - ); + const { + data, + error, + isLoading, + isFetching, + isPaused, + isPending, + isPlaceholderData, + refetch, + } = useGetManyAggregate( + reference, + { ids: [id], meta }, + otherQueryOptions + ); return { referenceRecord: error ? undefined : data ? data[0] : undefined, refetch, error, isLoading, isFetching, + isPaused, isPending, + isPlaceholderData, }; }; diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx index 59a60eeee5f..1a08c481125 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx @@ -28,6 +28,7 @@ import { SXNoLink, SlowAccessControl, Themed, + Offline, } from './ReferenceField.stories'; import { TextField } from './TextField'; @@ -763,4 +764,13 @@ describe('', () => { render(); expect(await screen.findByTestId('themed')).toBeDefined(); }); + + it('should render the offline prop node when offline', async () => { + render(); + fireEvent.click(await screen.findByText('Simulate offline')); + fireEvent.click(await screen.findByText('Toggle Child')); + await screen.findByText('No connectivity. Could not fetch data.'); + fireEvent.click(await screen.findByText('Simulate online')); + await screen.findByText('9780393966473'); + }); }); diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx index 36196f3df14..1abd5770274 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx @@ -12,13 +12,14 @@ import { Resource, TestMemoryRouter, AuthProvider, + useIsOffline, } from 'ra-core'; import fakeRestDataProvider from 'ra-data-fakerest'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import { createTheme, Stack, ThemeOptions } from '@mui/material'; -import { QueryClient } from '@tanstack/react-query'; +import { onlineManager, QueryClient } from '@tanstack/react-query'; import { deepmerge } from '@mui/utils'; import { TextField } from '../field'; @@ -971,3 +972,42 @@ export const WithRenderProp = () => ( > ); + +export const Offline = () => ( + + +
+ + + + + +
+ +
+
+); + +const SimulateOfflineButton = () => { + const isOffline = useIsOffline(); + return ( + + ); +}; + +const RenderChildOnDemand = ({ children }) => { + const [showChild, setShowChild] = React.useState(false); + return ( + <> + + {showChild &&
{children}
} + + ); +}; diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 7dc22b926e2..f6c73b47448 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -26,6 +26,7 @@ import { Link } from '../Link'; import type { FieldProps } from './types'; import { genericMemo } from './genericMemo'; import { visuallyHidden } from '@mui/utils'; +import { Offline } from '../Offline'; /** * Fetch reference record, and render its representation, or delegate rendering to child component. @@ -76,7 +77,14 @@ export const ReferenceField = < props: inProps, name: PREFIX, }); - const { children, render, emptyText, empty, ...rest } = props; + const { + children, + render, + emptyText, + empty, + offline = defaultOffline, + ...rest + } = props; const translate = useTranslate(); return ( @@ -95,6 +103,7 @@ export const ReferenceField = < empty ?? null ) } + offline={offline} > {...rest} @@ -106,6 +115,8 @@ export const ReferenceField = < ); }; +const defaultOffline = ; + export interface ReferenceFieldProps< RecordType extends Record = Record, ReferenceRecordType extends RaRecord = RaRecord, @@ -126,6 +137,7 @@ export interface ReferenceFieldProps< reference: string; translateChoice?: Function | boolean; link?: LinkToType; + offline?: ReactNode; sx?: SxProps; }