From aaf7860fbe5520cb77516339fa1e7211bd9e5817 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:06:37 +0100 Subject: [PATCH 01/58] Add support for offline/local first applications --- examples/simple/package.json | 2 + examples/simple/src/Layout.tsx | 91 +++++++++++++--- .../simple/src/getOfflineFirstQueryClient.ts | 39 +++++++ examples/simple/src/index.tsx | 101 +++++++++++------- examples/simple/src/posts/PostCreate.tsx | 23 +++- .../field/useReferenceArrayFieldController.ts | 62 ++++++----- .../field/useReferenceManyFieldController.ts | 4 + .../input/useReferenceInputController.ts | 1 + .../list/useInfiniteListController.ts | 4 + .../ra-core/src/controller/list/useList.ts | 6 ++ .../src/controller/list/useListController.ts | 6 ++ .../ra-core/src/dataProvider/useCreate.ts | 1 + .../ra-core/src/dataProvider/useDelete.ts | 1 + .../ra-core/src/dataProvider/useDeleteMany.ts | 1 + .../ra-core/src/dataProvider/useGetList.ts | 2 +- .../src/dataProvider/useGetManyReference.ts | 2 +- .../ra-core/src/dataProvider/useUpdate.ts | 1 + .../ra-core/src/dataProvider/useUpdateMany.ts | 1 + .../ra-ui-materialui/src/list/ListView.tsx | 31 +++--- .../src/list/SimpleList/SimpleList.tsx | 9 +- .../src/list/SingleFieldList.tsx | 10 +- .../src/list/datagrid/Datagrid.tsx | 9 +- yarn.lock | 33 ++++++ 23 files changed, 338 insertions(+), 102 deletions(-) create mode 100644 examples/simple/src/getOfflineFirstQueryClient.ts diff --git a/examples/simple/package.json b/examples/simple/package.json index a8456ff94eb..12ad3094774 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -12,8 +12,10 @@ "dependencies": { "@mui/icons-material": "^5.16.12", "@mui/material": "^5.16.12", + "@tanstack/query-sync-storage-persister": "5.47.0", "@tanstack/react-query": "^5.21.7", "@tanstack/react-query-devtools": "^5.21.7", + "@tanstack/react-query-persist-client": "5.47.0", "jsonexport": "^3.2.0", "lodash": "~4.17.5", "ra-data-fakerest": "^5.6.1", diff --git a/examples/simple/src/Layout.tsx b/examples/simple/src/Layout.tsx index f17e8f98f70..84d138da806 100644 --- a/examples/simple/src/Layout.tsx +++ b/examples/simple/src/Layout.tsx @@ -1,21 +1,80 @@ import * as React from 'react'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { AppBar, Layout, InspectorButton, TitlePortal } from 'react-admin'; +import { + AppBar, + Layout, + InspectorButton, + TitlePortal, + useNotify, +} from 'react-admin'; +import { onlineManager, useQueryClient } from '@tanstack/react-query'; +import { Stack, Tooltip } from '@mui/material'; +import CircleIcon from '@mui/icons-material/Circle'; import '../assets/app.css'; -const MyAppBar = () => ( - - - - -); +const MyAppBar = () => { + const isOnline = useIsOnline(); + return ( + + + + + + + + + + ); +}; -export default ({ children }) => ( - <> - {children} - - -); +export default ({ children }) => { + return ( + <> + + {children} + + + + + ); +}; + +const useIsOnline = () => { + const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); + React.useEffect(() => { + const handleChange = isOnline => { + setIsOnline(isOnline); + }; + return onlineManager.subscribe(handleChange); + }); + + return isOnline; +}; + +/** + * When react-query resume persisted mutations through their default functions (provided in the getOfflineFirstQueryClient file) after the browser tab + * has been closed, it cannot handle their side effects unless we set up some defaults. In order to leverage the react-admin notification system + * we add a default onSettled function to the mutation defaults here. + */ +const NotificationsFromQueryClient = () => { + const queryClient = useQueryClient(); + const notify = useNotify(); + + queryClient.setMutationDefaults([], { + onSettled(data, error) { + if (error) { + notify(error.message, { type: 'error' }); + } + }, + }); + return null; +}; diff --git a/examples/simple/src/getOfflineFirstQueryClient.ts b/examples/simple/src/getOfflineFirstQueryClient.ts new file mode 100644 index 00000000000..d6ee840d0a8 --- /dev/null +++ b/examples/simple/src/getOfflineFirstQueryClient.ts @@ -0,0 +1,39 @@ +import { QueryClient } from '@tanstack/react-query'; +import { DataProvider } from 'react-admin'; + +const DEFAULT_MUTATIONS = [ + 'create', + 'delete', + 'update', + 'updateMany', + 'deleteMany', +]; + +/** + * react-query requires default mutation functions to allow resumable mutations + * (e.g. mutations triggered while offline and users navigated away from the component that triggered them). + * This simple implementation does not handle custom mutations. + */ +export const getOfflineFirstQueryClient = ({ + queryClient, + dataProvider, + resources, + mutations = DEFAULT_MUTATIONS, +}: { + queryClient: QueryClient; + dataProvider: DataProvider; + resources: string[]; + mutations?: string[]; +}) => { + resources.forEach(resource => { + mutations.forEach(mutation => { + queryClient.setMutationDefaults([resource, mutation], { + mutationFn: async params => { + return dataProvider[mutation](resource, params); + }, + }); + }); + }); + + return queryClient; +}; diff --git a/examples/simple/src/index.tsx b/examples/simple/src/index.tsx index 0d9358167ad..cd086767bfc 100644 --- a/examples/simple/src/index.tsx +++ b/examples/simple/src/index.tsx @@ -3,7 +3,8 @@ import * as React from 'react'; import { Admin, Resource, CustomRoutes } from 'react-admin'; // eslint-disable-line import/no-unresolved import { createRoot } from 'react-dom/client'; import { Route } from 'react-router-dom'; - +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; import authProvider from './authProvider'; import comments from './comments'; import CustomRouteLayout from './customRouteLayout'; @@ -15,48 +16,74 @@ import posts from './posts'; import users from './users'; import tags from './tags'; import { queryClient } from './queryClient'; +import { getOfflineFirstQueryClient } from './getOfflineFirstQueryClient'; + +const localStoragePersister = createSyncStoragePersister({ + storage: window.localStorage, +}); + +const offlineFirstQueryClient = getOfflineFirstQueryClient({ + queryClient, + dataProvider, + resources: ['posts', 'comments', 'tags', 'users'], +}); const container = document.getElementById('root') as HTMLElement; const root = createRoot(container); root.render( - { + // resume mutations after initial restore from localStorage was successful + queryClient.resumePausedMutations(); + }} > - - - - - - } - /> - - } - /> - - - } - /> - - - } - /> - - + + + + + + + + } + /> + + } + /> + + + + } + /> + + + + } + /> + + + ); diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index 693953892c9..bfc9d22aeb1 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -30,6 +30,13 @@ import { import { useFormContext, useWatch } from 'react-hook-form'; import { Button, Dialog, DialogActions, DialogContent } from '@mui/material'; +const mutationMode = 'undoable'; +// Client side id generation. We start from 100 to avoid querying the post list to get the next id as we +// may be offline and accessing this page directly (without going through the list page first) which would +// be possible if the app was also a PWA. +let next_id = 100; +const getNewId = () => next_id++; + const PostCreateToolbar = () => { const notify = useNotify(); const redirect = useRedirect(); @@ -47,6 +54,7 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, + undoable: mutationMode === 'undoable', }); redirect('show', 'posts', data.id); }, @@ -64,6 +72,7 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, + undoable: mutationMode === 'undoable', }); }, }} @@ -77,11 +86,16 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, + undoable: mutationMode === 'undoable', }); redirect('show', 'posts', data.id); }, }} - transform={data => ({ ...data, average_note: 10 })} + transform={data => ({ + ...data, + id: getNewId(), + average_note: 10, + })} sx={{ display: { xs: 'none', sm: 'flex' } }} /> @@ -94,6 +108,7 @@ const backlinksDefaultValue = [ url: 'http://google.com', }, ]; + const PostCreate = () => { const defaultValues = useMemo( () => ({ @@ -103,7 +118,11 @@ const PostCreate = () => { ); const dateDefaultValue = useMemo(() => new Date(), []); return ( - + ({ ...data, id: getNewId() })} + > } defaultValues={defaultValues} diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts index 73d4660b456..a657a681ba1 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts @@ -76,32 +76,40 @@ export const useReferenceArrayFieldController = < const { meta, ...otherQueryOptions } = queryOptions; const ids = Array.isArray(value) ? value : emptyArray; - const { data, error, isLoading, isFetching, isPending, refetch } = - useGetManyAggregate( - reference, - { ids, meta }, - { - 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, + isLoading, + isFetching, + isPaused, + isPending, + isPlaceholderData, + refetch, + } = useGetManyAggregate( + reference, + { ids, meta }, + { + 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 listProps = useList({ data, @@ -109,7 +117,9 @@ export const useReferenceArrayFieldController = < filter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, page, perPage, sort, diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index e74a3a3ceef..9d64ed01d30 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -168,7 +168,9 @@ export const useReferenceManyFieldController = < error, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, refetch, } = useGetManyReference( reference, @@ -270,7 +272,9 @@ export const useReferenceManyFieldController = < hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 7ddbced53d2..019e483f4c6 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -118,6 +118,7 @@ export const useReferenceInputController = ( reference, // @ts-ignore the types of the queryOptions for the getMAny and getList are not compatible options: { + // @ts-ignore FIXME enabled: currentValue != null && currentValue !== '', meta, ...otherQueryOptions, diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts index 08be5579655..81dc25c2789 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.ts +++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts @@ -103,7 +103,9 @@ export const useInfiniteListController = < total, error, isLoading, + isPaused, isPending, + isPlaceholderData, isFetching, hasNextPage, hasPreviousPage, @@ -202,7 +204,9 @@ export const useInfiniteListController = < hideFilter: queryModifiers.hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index d6a9b34bc39..71994cf6259 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -62,6 +62,8 @@ export const useList = ( isFetching = false, isLoading = false, isPending = false, + isPaused = false, + isPlaceholderData = false, page: initialPage = 1, perPage: initialPerPage = 1000, sort: initialSort, @@ -289,6 +291,8 @@ export const useList = ( isFetching: fetchingState, isLoading: loadingState, isPending: pendingState, + isPaused, + isPlaceholderData, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, @@ -317,6 +321,8 @@ export interface UseListOptions< isFetching?: boolean; isLoading?: boolean; isPending?: boolean; + isPaused?: boolean; + isPlaceholderData?: boolean; page?: number; perPage?: number; sort?: SortPayload; diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 16479e9a06c..9ef42762b1d 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -115,7 +115,9 @@ export const useListController = < error, isLoading, isFetching, + isPaused, isPending, + isPlaceholderData, refetch, } = useGetList( resource, @@ -203,7 +205,9 @@ export const useListController = < hideFilter: queryModifiers.hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, @@ -529,6 +533,8 @@ export interface ListControllerBaseResult { hasPreviousPage?: boolean; isFetching?: boolean; isLoading?: boolean; + isPaused?: boolean; + isPlaceholderData?: boolean; } export interface ListControllerLoadingResult diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index 462e36e2ace..aa23b526861 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -152,6 +152,7 @@ export const useCreate = < MutationError, Partial> >({ + mutationKey: [resource, 'create', params], mutationFn: ({ resource: callTimeResource = resource, data: callTimeData = paramsRef.current.data, diff --git a/packages/ra-core/src/dataProvider/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts index 25cfd6a9133..586f25a0558 100644 --- a/packages/ra-core/src/dataProvider/useDelete.ts +++ b/packages/ra-core/src/dataProvider/useDelete.ts @@ -194,6 +194,7 @@ export const useDelete = < MutationError, Partial> >({ + mutationKey: [resource, 'delete', params], mutationFn: ({ resource: callTimeResource = resource, id: callTimeId = paramsRef.current.id, diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts index dceb34cb667..92031824e6d 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.ts +++ b/packages/ra-core/src/dataProvider/useDeleteMany.ts @@ -220,6 +220,7 @@ export const useDeleteMany = < MutationError, Partial> >({ + mutationKey: [resource, 'deleteMany', params], mutationFn: ({ resource: callTimeResource = resource, ids: callTimeIds = paramsRef.current.ids, diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index aa24430d175..e16296dfb3a 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -176,7 +176,7 @@ export const useGetList = < } : result, [result] - ) as UseQueryResult & { + ) as unknown as UseQueryResult & { total?: number; pageInfo?: { hasNextPage?: boolean; diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index 001e930fc6b..c6639a9147d 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -155,7 +155,7 @@ export const useGetManyReference = < } : result, [result] - ) as UseQueryResult & { + ) as unknown as UseQueryResult & { total?: number; pageInfo?: { hasNextPage?: boolean; diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 9e3e53c21e8..b4cd0a055da 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -196,6 +196,7 @@ export const useUpdate = ( ErrorType, Partial> >({ + mutationKey: [resource, 'update', params], mutationFn: ({ resource: callTimeResource = resource, id: callTimeId = paramsRef.current.id, diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts index 57f3564e9b7..9e9e64a2961 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -192,6 +192,7 @@ export const useUpdateMany = < MutationError, Partial> >({ + mutationKey: [resource, 'updateMany', params], mutationFn: ({ resource: callTimeResource = resource, ids: callTimeIds = paramsRef.current.ids, diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index f3aa291fe23..36d06172371 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -37,7 +37,9 @@ export const ListView = ( defaultTitle, data, error, + isPaused, isPending, + isPlaceholderData, filterValues, resource, total, @@ -67,20 +69,21 @@ export const ListView = ( empty !== false &&
{empty}
; const shouldRenderEmptyPage = - !error && - // the list is not loading data for the first time - !isPending && - // the API returned no data (using either normal or partial pagination) - (total === 0 || - (total == null && - hasPreviousPage === false && - hasNextPage === false && - // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it - data.length === 0)) && - // the user didn't set any filters - !Object.keys(filterValues).length && - // there is an empty page component - empty !== false; + (isPaused && isPlaceholderData) || + (!error && + // the list is not loading data for the first time + !isPending && + // the API returned no data (using either normal or partial pagination) + (total === 0 || + (total == null && + hasPreviousPage === false && + hasNextPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + data.length === 0)) && + // the user didn't set any filters + !Object.keys(filterValues).length && + // there is an empty page component + empty !== false); return ( diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index d9c03362c2d..b4a91a18329 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -88,7 +88,7 @@ export const SimpleList = ( resource, ...rest } = props; - const { data, isPending, total } = + const { data, isPaused, isPending, isPlaceholderData, total } = useListContextWithProps(props); if (isPending === true) { @@ -103,7 +103,12 @@ export const SimpleList = ( ); } - if (data == null || data.length === 0 || total === 0) { + if ( + data == null || + data.length === 0 || + total === 0 || + (isPaused && isPlaceholderData) + ) { if (empty) { return empty; } diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index 796de8a19eb..d487289a7a6 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -57,7 +57,8 @@ export const SingleFieldList = (props: SingleFieldListProps) => { direction = 'row', ...rest } = props; - const { data, total, isPending } = useListContextWithProps(props); + const { data, total, isPaused, isPending, isPlaceholderData } = + useListContextWithProps(props); const resource = useResourceContext(props); const createPath = useCreatePath(); @@ -65,7 +66,12 @@ export const SingleFieldList = (props: SingleFieldListProps) => { return ; } - if (data == null || data.length === 0 || total === 0) { + if ( + data == null || + data.length === 0 || + total === 0 || + (isPaused && isPlaceholderData) + ) { if (empty) { return empty; } diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index ea532f1ad43..77b5f341032 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -151,7 +151,9 @@ export const Datagrid: React.ForwardRefExoticComponent< const { sort, data, + isPaused, isPending, + isPlaceholderData, onSelect, onToggleItem, selectedIds, @@ -226,7 +228,12 @@ export const Datagrid: React.ForwardRefExoticComponent< * displaying the table header with zero data rows, * the Datagrid displays the empty component. */ - if (data == null || data.length === 0 || total === 0) { + if ( + data == null || + data.length === 0 || + total === 0 || + (isPaused && isPlaceholderData) + ) { if (empty) { return empty; } diff --git a/yarn.lock b/yarn.lock index 2d2bfad7b08..239eba7c8c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4542,6 +4542,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-persist-client-core@npm:5.47.0": + version: 5.47.0 + resolution: "@tanstack/query-persist-client-core@npm:5.47.0" + dependencies: + "@tanstack/query-core": "npm:5.47.0" + checksum: b82e532bd618fd0f4b57eb653f88f0777d36db872184b5cff8033772778f838960ffbf46174cc902cb2260d7065dda4ce6f9b216e6b5d13f67cabd425a66a884 + languageName: node + linkType: hard + +"@tanstack/query-sync-storage-persister@npm:5.47.0": + version: 5.47.0 + resolution: "@tanstack/query-sync-storage-persister@npm:5.47.0" + dependencies: + "@tanstack/query-core": "npm:5.47.0" + "@tanstack/query-persist-client-core": "npm:5.47.0" + checksum: f82e1b68db259170711aaa5b76684d23131e9d272ffc78703583370823c21c06fedb8cd5e61f6df5228a369356b5527db8b6d9e467930374f942d1e70e34fea0 + languageName: node + linkType: hard + "@tanstack/react-query-devtools@npm:^5.21.7": version: 5.47.0 resolution: "@tanstack/react-query-devtools@npm:5.47.0" @@ -4554,6 +4573,18 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query-persist-client@npm:5.47.0": + version: 5.47.0 + resolution: "@tanstack/react-query-persist-client@npm:5.47.0" + dependencies: + "@tanstack/query-persist-client-core": "npm:5.47.0" + peerDependencies: + "@tanstack/react-query": ^5.47.0 + react: ^18 || ^19 + checksum: 0bd0988f03811c5dcdc49c53f40a89495c1c11c9697eb15800d44c48ba626eb22fc421ca9348f84186406ec7652c10e36bde77790eddd14b934a38a90b488af4 + languageName: node + linkType: hard + "@tanstack/react-query@npm:^5.21.7": version: 5.47.0 resolution: "@tanstack/react-query@npm:5.47.0" @@ -18297,8 +18328,10 @@ __metadata: "@hookform/devtools": "npm:^4.3.3" "@mui/icons-material": "npm:^5.16.12" "@mui/material": "npm:^5.16.12" + "@tanstack/query-sync-storage-persister": "npm:5.47.0" "@tanstack/react-query": "npm:^5.21.7" "@tanstack/react-query-devtools": "npm:^5.21.7" + "@tanstack/react-query-persist-client": "npm:5.47.0" "@vitejs/plugin-react": "npm:^4.2.1" jsonexport: "npm:^3.2.0" little-state-machine: "npm:^4.8.1" From 2aa2cba87314cc2b406f56c9b7764e968fcd5604 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:55:30 +0100 Subject: [PATCH 02/58] Ensure no existing tests are broken --- examples/simple/src/posts/PostCreate.tsx | 46 ++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index bfc9d22aeb1..d6e82f9afdf 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -28,14 +28,40 @@ import { CanAccess, } from 'react-admin'; import { useFormContext, useWatch } from 'react-hook-form'; -import { Button, Dialog, DialogActions, DialogContent } from '@mui/material'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, +} from '@mui/material'; + +/** + * To ensure no existing tests are broken, we keep the same mutation mode as before by default. + * In order to test the new optimistic or undoable modes, you can add `mutationMode=optimistic` or `mutationMode=undoable` + * to the query string. + */ +const getMutationMode = () => { + const querystring = new URLSearchParams(window.location.search); + const mutationMode = querystring.get('mutationMode'); + switch (mutationMode) { + case 'undoable': + return 'undoable'; + case 'optimistic': + return 'optimistic'; + default: + return 'pessimistic'; + } +}; -const mutationMode = 'undoable'; // Client side id generation. We start from 100 to avoid querying the post list to get the next id as we // may be offline and accessing this page directly (without going through the list page first) which would // be possible if the app was also a PWA. +// We only do that for optimistic and undoable modes in order to not break any existing tests that expect +// the id to be generated by the server (e.g. by FakeRest). let next_id = 100; -const getNewId = () => next_id++; +const getNewId = () => + getMutationMode() === 'pessimistic' ? undefined : next_id++; const PostCreateToolbar = () => { const notify = useNotify(); @@ -54,7 +80,7 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: mutationMode === 'undoable', + undoable: getMutationMode() === 'undoable', }); redirect('show', 'posts', data.id); }, @@ -72,7 +98,7 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: mutationMode === 'undoable', + undoable: getMutationMode() === 'undoable', }); }, }} @@ -86,7 +112,7 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: mutationMode === 'undoable', + undoable: getMutationMode() === 'undoable', }); redirect('show', 'posts', data.id); }, @@ -120,9 +146,15 @@ const PostCreate = () => { return ( ({ ...data, id: getNewId() })} > + + To test offline support, add either{' '} + ?mutationMode=optimistic or{' '} + ?mutationMode=undoable to the page search + parameters. + } defaultValues={defaultValues} From a1b2157000d73ab1167bd26fb07e9cb9e904b910 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:18:14 +0100 Subject: [PATCH 03/58] Try stabilizing tests --- cypress/e2e/edit.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/edit.cy.js b/cypress/e2e/edit.cy.js index 9f85eecb013..c550b4425ed 100644 --- a/cypress/e2e/edit.cy.js +++ b/cypress/e2e/edit.cy.js @@ -47,6 +47,7 @@ describe('Edit Page', () => { it('should redirect to list page after edit success', () => { // For some unknown reason, the click on submit didn't work in cypress // so we submit with enter + EditPostPage.waitUntilVisible(); EditPostPage.setInputValue('input', 'title', 'Lorem Ipsum{enter}'); cy.url().should('match', /\/#\/posts$/); }); From 9d66e05e5f3fa862061fd6753c5d8ea6b5534f7f Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 25 Feb 2025 09:28:23 +0100 Subject: [PATCH 04/58] Fix e2e tests --- cypress/e2e/edit.cy.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/edit.cy.js b/cypress/e2e/edit.cy.js index c550b4425ed..b53aabaf5ba 100644 --- a/cypress/e2e/edit.cy.js +++ b/cypress/e2e/edit.cy.js @@ -47,8 +47,11 @@ describe('Edit Page', () => { it('should redirect to list page after edit success', () => { // For some unknown reason, the click on submit didn't work in cypress // so we submit with enter - EditPostPage.waitUntilVisible(); - EditPostPage.setInputValue('input', 'title', 'Lorem Ipsum{enter}'); + EditPostPage.setInputValue( + 'input', + 'title', + 'Lorem Ipsum again{enter}' + ); cy.url().should('match', /\/#\/posts$/); }); From fe15863acc78bafb09105e8db50d00ab314ccd5d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:12:39 +0200 Subject: [PATCH 05/58] Introduce `addOfflineSupportToQueryClient` --- .../simple/src/getOfflineFirstQueryClient.ts | 39 ------- examples/simple/src/index.tsx | 16 +-- examples/simple/src/posts/PostCreate.tsx | 78 ++++++++------ .../addOfflineSupportToQueryClient.ts | 102 ++++++++++++++++++ .../src/dataProvider/dataFetchActions.ts | 8 ++ packages/ra-core/src/dataProvider/index.ts | 1 + .../ra-core/src/dataProvider/useCreate.ts | 6 +- 7 files changed, 173 insertions(+), 77 deletions(-) delete mode 100644 examples/simple/src/getOfflineFirstQueryClient.ts create mode 100644 packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts diff --git a/examples/simple/src/getOfflineFirstQueryClient.ts b/examples/simple/src/getOfflineFirstQueryClient.ts deleted file mode 100644 index d6ee840d0a8..00000000000 --- a/examples/simple/src/getOfflineFirstQueryClient.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { QueryClient } from '@tanstack/react-query'; -import { DataProvider } from 'react-admin'; - -const DEFAULT_MUTATIONS = [ - 'create', - 'delete', - 'update', - 'updateMany', - 'deleteMany', -]; - -/** - * react-query requires default mutation functions to allow resumable mutations - * (e.g. mutations triggered while offline and users navigated away from the component that triggered them). - * This simple implementation does not handle custom mutations. - */ -export const getOfflineFirstQueryClient = ({ - queryClient, - dataProvider, - resources, - mutations = DEFAULT_MUTATIONS, -}: { - queryClient: QueryClient; - dataProvider: DataProvider; - resources: string[]; - mutations?: string[]; -}) => { - resources.forEach(resource => { - mutations.forEach(mutation => { - queryClient.setMutationDefaults([resource, mutation], { - mutationFn: async params => { - return dataProvider[mutation](resource, params); - }, - }); - }); - }); - - return queryClient; -}; diff --git a/examples/simple/src/index.tsx b/examples/simple/src/index.tsx index cd086767bfc..680f7f6f634 100644 --- a/examples/simple/src/index.tsx +++ b/examples/simple/src/index.tsx @@ -1,6 +1,11 @@ /* eslint react/jsx-key: off */ import * as React from 'react'; -import { Admin, Resource, CustomRoutes } from 'react-admin'; // eslint-disable-line import/no-unresolved +import { + addOfflineSupportToQueryClient, + Admin, + Resource, + CustomRoutes, +} from 'react-admin'; import { createRoot } from 'react-dom/client'; import { Route } from 'react-router-dom'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; @@ -16,13 +21,12 @@ import posts from './posts'; import users from './users'; import tags from './tags'; import { queryClient } from './queryClient'; -import { getOfflineFirstQueryClient } from './getOfflineFirstQueryClient'; const localStoragePersister = createSyncStoragePersister({ storage: window.localStorage, }); -const offlineFirstQueryClient = getOfflineFirstQueryClient({ +addOfflineSupportToQueryClient({ queryClient, dataProvider, resources: ['posts', 'comments', 'tags', 'users'], @@ -34,10 +38,10 @@ const root = createRoot(container); root.render( { - // resume mutations after initial restore from localStorage was successful + // resume mutations after initial restore from localStorage is successful queryClient.resumePausedMutations(); }} > @@ -45,7 +49,7 @@ root.render( authProvider={authProvider} dataProvider={dataProvider} i18nProvider={i18nProvider} - queryClient={offlineFirstQueryClient} + queryClient={queryClient} title="Example Admin" layout={Layout} > diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index d6e82f9afdf..7af974bdeff 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -26,6 +26,7 @@ import { useCreate, useCreateSuggestionContext, CanAccess, + MutationMode, } from 'react-admin'; import { useFormContext, useWatch } from 'react-hook-form'; import { @@ -35,24 +36,7 @@ import { DialogActions, DialogContent, } from '@mui/material'; - -/** - * To ensure no existing tests are broken, we keep the same mutation mode as before by default. - * In order to test the new optimistic or undoable modes, you can add `mutationMode=optimistic` or `mutationMode=undoable` - * to the query string. - */ -const getMutationMode = () => { - const querystring = new URLSearchParams(window.location.search); - const mutationMode = querystring.get('mutationMode'); - switch (mutationMode) { - case 'undoable': - return 'undoable'; - case 'optimistic': - return 'optimistic'; - default: - return 'pessimistic'; - } -}; +import { Link, useSearchParams } from 'react-router-dom'; // Client side id generation. We start from 100 to avoid querying the post list to get the next id as we // may be offline and accessing this page directly (without going through the list page first) which would @@ -60,10 +44,17 @@ const getMutationMode = () => { // We only do that for optimistic and undoable modes in order to not break any existing tests that expect // the id to be generated by the server (e.g. by FakeRest). let next_id = 100; -const getNewId = () => - getMutationMode() === 'pessimistic' ? undefined : next_id++; +const getNewId = (mutationMode: MutationMode) => { + const id = mutationMode === 'pessimistic' ? undefined : next_id++; + console.log({ mutationMode, id }); + return id; +}; -const PostCreateToolbar = () => { +const PostCreateToolbar = ({ + mutationMode, +}: { + mutationMode: MutationMode; +}) => { const notify = useNotify(); const redirect = useRedirect(); const { reset } = useFormContext(); @@ -80,11 +71,16 @@ const PostCreateToolbar = () => { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: getMutationMode() === 'undoable', + undoable: mutationMode === 'undoable', }); redirect('show', 'posts', data.id); }, }} + transform={data => ({ + ...data, + id: getNewId(mutationMode), + average_note: 10, + })} sx={{ display: { xs: 'none', sm: 'flex' } }} /> { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: getMutationMode() === 'undoable', + undoable: mutationMode === 'undoable', }); }, }} + transform={data => ({ + ...data, + id: getNewId(mutationMode), + average_note: 10, + })} /> { notify('resources.posts.notifications.created', { type: 'info', messageArgs: { smart_count: 1 }, - undoable: getMutationMode() === 'undoable', + undoable: mutationMode === 'undoable', }); redirect('show', 'posts', data.id); }, }} transform={data => ({ ...data, - id: getNewId(), + id: getNewId(mutationMode), average_note: 10, })} sx={{ display: { xs: 'none', sm: 'flex' } }} @@ -135,6 +136,15 @@ const backlinksDefaultValue = [ }, ]; +const useMutationMode = (): MutationMode => { + const [searchParams] = useSearchParams(); + const mutationMode = searchParams.get('mutationMode') ?? 'pessimistic'; + + return ['optimistic', 'undoable', 'pessimistic'].includes(mutationMode) + ? (mutationMode as MutationMode) + : 'pessimistic'; +}; + const PostCreate = () => { const defaultValues = useMemo( () => ({ @@ -142,21 +152,27 @@ const PostCreate = () => { }), [] ); + const mutationMode = useMutationMode(); const dateDefaultValue = useMemo(() => new Date(), []); return ( ({ ...data, id: getNewId() })} + mutationMode={mutationMode} + transform={data => ({ ...data, id: getNewId(mutationMode) })} > To test offline support, add either{' '} - ?mutationMode=optimistic or{' '} - ?mutationMode=undoable to the page search - parameters. + + ?mutationMode=optimistic + {' '} + or + + ?mutationMode=undoable + {' '} + to the page search parameters. } + toolbar={} defaultValues={defaultValues} sx={{ maxWidth: { md: 'auto', lg: '30em' } }} > diff --git a/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts new file mode 100644 index 00000000000..c99cb0f22e5 --- /dev/null +++ b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts @@ -0,0 +1,102 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { reactAdminMutations } from './dataFetchActions'; +import type { DataProvider } from '../types'; + +/** + * A function that registers default functions on the queryClient for the specified mutations and resources. + * react-query requires default mutation functions to allow resumable mutations + * (e.g. mutations triggered while offline and users navigated away from the component that triggered them). + * + * @example Adding offline support for the default mutations + * // in src/App.tsx + * import { Admin, Resource, addOfflineSupportToQueryClient, reactAdminMutations } from 'react-admin'; + * import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; + * import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; + * import { queryClient } from './queryClient'; + * import { dataProvider } from './dataProvider'; + * import { posts } from './posts'; + * import { comments } from './comments'; + * + * const localStoragePersister = createSyncStoragePersister({ + * storage: window.localStorage, + * }); + * + * addOfflineSupportToQueryClient({ + * queryClient, + * dataProvider, + * resources: ['posts', 'comments'], + * mutations: [...reactAdminMutations, 'myCustomMutation'], + * }); + * + * const App = () => ( + * { + * // resume mutations after initial restore from localStorage was successful + * queryClient.resumePausedMutations(); + * }} + * > + * + * + * + * + * + * ); + * + * @example Adding offline support with custom mutations + * // in src/App.tsx + * import { addOfflineSupportToQueryClient } from 'react-admin'; + * import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; + * import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; + * import { queryClient } from './queryClient'; + * import { dataProvider } from './dataProvider'; + * import { posts } from './posts'; + * import { comments } from './comments'; + * + * const localStoragePersister = createSyncStoragePersister({ + * storage: window.localStorage, + * }); + * + * addOfflineSupportToQueryClient({ + * queryClient, + * dataProvider, + * resources: ['posts', 'comments'], + * }); + * + * const App = () => ( + * { + * // resume mutations after initial restore from localStorage was successful + * queryClient.resumePausedMutations(); + * }} + * > + * + * + * + * + * + * ); + */ +export const addOfflineSupportToQueryClient = ({ + dataProvider, + resources, + queryClient, +}: { + dataProvider: DataProvider; + resources: string[]; + queryClient: QueryClient; +}) => { + resources.forEach(resource => { + reactAdminMutations.forEach(mutation => { + queryClient.setMutationDefaults([resource, mutation], { + mutationFn: async params => { + const dataProviderFn = dataProvider[mutation] as Function; + return dataProviderFn.apply(dataProviderFn, ...params); + }, + }); + }); + }); +}; diff --git a/packages/ra-core/src/dataProvider/dataFetchActions.ts b/packages/ra-core/src/dataProvider/dataFetchActions.ts index 249d90a7647..30ed285ba92 100644 --- a/packages/ra-core/src/dataProvider/dataFetchActions.ts +++ b/packages/ra-core/src/dataProvider/dataFetchActions.ts @@ -8,6 +8,14 @@ export const UPDATE_MANY = 'UPDATE_MANY'; export const DELETE = 'DELETE'; export const DELETE_MANY = 'DELETE_MANY'; +export const reactAdminMutations = [ + 'create', + 'delete', + 'update', + 'updateMany', + 'deleteMany', +]; + export const fetchActionsWithRecordResponse = ['getOne', 'create', 'update']; export const fetchActionsWithArrayOfIdentifiedRecordsResponse = [ 'getList', diff --git a/packages/ra-core/src/dataProvider/index.ts b/packages/ra-core/src/dataProvider/index.ts index ed5bd65b373..fe058181278 100644 --- a/packages/ra-core/src/dataProvider/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -4,6 +4,7 @@ import HttpError from './HttpError'; import * as fetchUtils from './fetch'; import undoableEventEmitter from './undoableEventEmitter'; +export * from './addOfflineSupportToQueryClient'; export * from './combineDataProviders'; export * from './dataFetchActions'; export * from './defaultDataProvider'; diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index aa23b526861..7d3a3ca8c15 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useMutation, UseMutationOptions, @@ -96,7 +96,11 @@ export const useCreate = < getMutateWithMiddlewares, ...mutationOptions } = options; + const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); const paramsRef = useRef>>>(params); From 1abc4460103d5b985ea9b266a8290567113f0bd8 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:13:07 +0200 Subject: [PATCH 06/58] Add documentation --- docs/DataProviders.md | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index db5bdc3654d..a2403def6fe 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -884,3 +884,136 @@ export default App; ``` **Tip**: This example uses the function version of `setState` (`setDataProvider(() => dataProvider)`) instead of the more classic version (`setDataProvider(dataProvider)`). This is because some legacy Data Providers are actually functions, and `setState` would call them immediately on mount. + +--- +layout: default +title: "Offline Support" +--- + +## Offline Support + +React Query supports offline/local-first applications. To enable it in your React Admin application, install the required React Query packages: + +```sh +yarn add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister +``` + +Then, register default functions for React Admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React Admin provides the `addOfflineSupportToQueryClient` function for this: + +```ts +// in src/queryClient.ts +import { addOfflineSupportToQueryClient } from 'react-admin'; +import { QueryClient } from '@tanstack/react-query'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { dataProvider } from './dataProvider'; + +export const queryClient = new QueryClient(); + +addOfflineSupportToQueryClient({ + queryClient, + dataProvider, + resources: ['posts', 'comments'], +}); +``` + +Then, wrap your `` inside a [``](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#persistqueryclientprovider): + +{% raw %} +```tsx +// in src/App.tsx +import { Admin, Resource } from 'react-admin'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { queryClient } from './queryClient'; +import { dataProvider } from './dataProvider'; +import { posts } from './posts'; +import { comments } from './comments'; + +const localStoragePersister = createSyncStoragePersister({ + storage: window.localStorage, +}); + +export const App = () => ( + { + // resume mutations after initial restore from localStorage is successful + queryClient.resumePausedMutations(); + }} + > + + + + + +) +``` +{% endraw %} + +If you have [custom mutations](./Actions.md#calling-custom-methods) on your dataProvider, you can enable offline support for them too. For instance, if your `dataProvider` exposes a `banUser()` method: + +```ts +const dataProvider = { + getList: /* ... */, + getOne: /* ... */, + getMany: /* ... */, + getManyReference: /* ... */, + create: /* ... */, + update: /* ... */, + updateMany: /* ... */, + delete: /* ... */, + deleteMany: /* ... */, + banUser: (userId: string) => { + return fetch(`/api/user/${userId}/ban`, { method: 'POST' }) + .then(response => response.json()); + }, +} + +export type MyDataProvider = DataProvider & { + banUser: (userId: string) => Promise<{ data: RaRecord }> +} +``` + +First, you must set a `mutationKey` for this mutation: + +{% raw %} +```tsx +const BanUserButton = ({ userId }: { userId: string }) => { + const dataProvider = useDataProvider(); + const { mutate, isPending } = useMutation({ + mutationKey: 'banUser' + mutationFn: (userId) => dataProvider.banUser(userId) + }); + return + + + + {({ TransitionProps, placement }) => ( + + + + + {MutationModes.map(mutationMode => ( + + handleMenuItemClick( + mutationMode + ) + } + > + {mutationMode} + + ))} + + + + + )} + + + ); +}; From 07121fd85dc7d065b69301df337cc31cc3fb1d80 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 12 May 2025 09:15:11 +0200 Subject: [PATCH 17/58] Fix e2e tests --- cypress/support/CreatePage.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cypress/support/CreatePage.js b/cypress/support/CreatePage.js index 138410b9721..65ef03c1dc7 100644 --- a/cypress/support/CreatePage.js +++ b/cypress/support/CreatePage.js @@ -11,13 +11,14 @@ export default url => ({ inputs: `.ra-input`, richTextInputError: '.create-page .ra-rich-text-input-error', snackbar: 'div[role="alert"]', - submitButton: ".create-page div[role='toolbar'] button[type='submit']", + submitButton: + ".create-page div[role='toolbar'] div:first-child button[type='submit']", submitAndShowButton: - ".create-page form div[role='toolbar'] button[type='button']:nth-child(2)", + ".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(2)", submitAndAddButton: - ".create-page form div[role='toolbar'] button[type='button']:nth-child(3)", + ".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(3)", submitCommentable: - ".create-page form div[role='toolbar'] button[type='button']:last-child", + ".create-page form div[role='toolbar'] div:first-child button[type='button']:last-child", descInput: '.ProseMirror', tab: index => `.form-tab:nth-of-type(${index})`, title: '#react-admin-title', From 233ce57e6a19e7f7fa8f4bb90a3f7503acc747bd Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 11:20:50 +0200 Subject: [PATCH 18/58] Add offline message to ReferenceField --- .../ra-core/src/controller/useReference.ts | 24 ++++++--- packages/ra-language-english/src/index.ts | 3 ++ packages/ra-language-french/src/index.ts | 3 ++ .../src/field/ReferenceField.stories.tsx | 12 +++++ .../src/field/ReferenceField.tsx | 53 +++++++++++++++---- 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/packages/ra-core/src/controller/useReference.ts b/packages/ra-core/src/controller/useReference.ts index b52526226c9..112b23d8693 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,12 +70,20 @@ 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, + isPending, + isPaused, + isPlaceholderData, + refetch, + } = useGetManyAggregate( + reference, + { ids: [id], meta }, + otherQueryOptions + ); return { referenceRecord: error ? undefined : data ? data[0] : undefined, refetch, @@ -81,5 +91,7 @@ export const useReference = < isLoading, isFetching, isPending, + isPaused, + isPlaceholderData, }; }; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 0e665685b0f..3e0f0370f0e 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -80,6 +80,8 @@ const englishMessages: TranslationMessages = { 'At least one of the associated references no longer appears to be available.', single_missing: 'Associated reference no longer appears to be available.', + single_offline: + 'The associated reference cannot be fetched as you are offline.', }, password: { toggle_visible: 'Hide password', @@ -171,6 +173,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 fb450792c02..90a3e8de19a 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -82,6 +82,8 @@ const frenchMessages: TranslationMessages = { 'Au moins une des références associées semble ne plus être disponible.', single_missing: 'La référence associée ne semble plus disponible.', + single_offline: + 'La référence associée ne peut être chargée car vous êtes hors ligne.', }, password: { toggle_visible: 'Cacher le mot de passe', @@ -179,6 +181,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/field/ReferenceField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx index acb3f0653fd..6f3466fc822 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx @@ -114,6 +114,18 @@ export const Loading = () => ( ); +const errorDataProvider = { + getMany: () => Promise.reject(new Error('Network error')), +}; + +export const ErrorWhileFetching = () => ( + + + + + +); + export const MissingReferenceId = () => ( diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index a2c8efab4b1..caff3d77d09 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ReactNode } from 'react'; -import { Typography } from '@mui/material'; +import { Stack, Typography } from '@mui/material'; import { type ComponentsOverrides, styled, @@ -25,7 +25,6 @@ import { LinearProgress } from '../layout'; import { Link } from '../Link'; import type { FieldProps } from './types'; import { genericMemo } from './genericMemo'; -import { visuallyHidden } from '@mui/utils'; /** * Fetch reference record, and render its representation, or delegate rendering to child component. @@ -113,8 +112,15 @@ export const ReferenceFieldView = < >( props: ReferenceFieldViewProps ) => { - const { children, className, emptyText, reference, sx } = props; - const { error, link, isLoading, referenceRecord } = + const { + children, + className, + emptyText, + offline = 'ra.notification.offline', + reference, + sx, + } = props; + const { error, link, isLoading, isPaused, referenceRecord } = useReferenceFieldContext(); const getRecordRepresentation = useGetRecordRepresentation(reference); @@ -122,12 +128,18 @@ export const ReferenceFieldView = < if (error) { return ( -
+ - - {typeof error === 'string' ? error : error?.message} - -
+ + {translate('ra.notification.http_error', { + _: 'Server communication error', + })} + + ); } // We explicitly check isLoading here as the record may not have an id for the reference, @@ -137,6 +149,28 @@ export const ReferenceFieldView = < return ; } if (!referenceRecord) { + if (isPaused) { + if (typeof offline === 'string') { + return ( + + + + {offline && translate(offline, { _: offline })} + + + ); + } + // We either have a ReactNode, a boolean or null|undefined + return offline || null; + } return emptyText ? ( {emptyText && translate(emptyText, { _: emptyText })} @@ -181,6 +215,7 @@ export interface ReferenceFieldViewProps< > extends FieldProps, Omit, 'link'> { children?: ReactNode; + offline?: ReactNode; reference: string; resource?: string; translateChoice?: Function | boolean; From f88c509538d6f496d5ec23004196b5358a423580 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 11:21:26 +0200 Subject: [PATCH 19/58] Add offline support to list components --- .../ra-core/src/dataTable/DataTableBase.tsx | 19 +++-- packages/ra-ui-materialui/src/Offline.tsx | 69 +++++++++++++++++++ .../src/field/ReferenceArrayField.stories.tsx | 65 ++++++++++++++++- .../src/field/ReferenceArrayField.tsx | 4 +- .../src/list/List.stories.tsx | 5 +- .../ra-ui-materialui/src/list/ListView.tsx | 31 +++++---- .../src/list/SimpleList/SimpleList.tsx | 27 +++++--- .../src/list/SingleFieldList.stories.tsx | 28 +++++--- .../src/list/SingleFieldList.tsx | 25 +++++-- .../src/list/datagrid/Datagrid.tsx | 38 ++++++++-- .../src/list/datatable/DataTable.tsx | 23 +++++++ packages/ra-ui-materialui/src/list/index.ts | 1 + 12 files changed, 278 insertions(+), 57 deletions(-) create mode 100644 packages/ra-ui-materialui/src/Offline.tsx diff --git a/packages/ra-core/src/dataTable/DataTableBase.tsx b/packages/ra-core/src/dataTable/DataTableBase.tsx index e81061ab177..cda81dc0f47 100644 --- a/packages/ra-core/src/dataTable/DataTableBase.tsx +++ b/packages/ra-core/src/dataTable/DataTableBase.tsx @@ -31,6 +31,7 @@ export const DataTableBase = function DataTable< loading, isRowSelectable, isRowExpandable, + offline, resource, rowClick, expandSingle = false, @@ -147,7 +148,7 @@ export const DataTableBase = function DataTable< ] ); - if (isPending === true) { + if (isPending && !isPaused) { return loading; } @@ -157,10 +158,9 @@ export const DataTableBase = function DataTable< * the DataTable displays the empty component. */ if ( - data == null || - data.length === 0 || - total === 0 || - (isPaused && isPlaceholderData) + (data == null || data.length === 0 || total === 0) && + !isPaused && + !isPlaceholderData ) { if (empty) { return empty; @@ -169,6 +169,14 @@ export const DataTableBase = function DataTable< return null; } + if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (offline) { + return offline; + } + + return null; + } + /** * After the initial load, if the data for the list isn't empty, * and even if the data is refreshing (e.g. after a filter change), @@ -213,6 +221,7 @@ export interface DataTableBaseProps { hasBulkActions: boolean; hover?: boolean; empty: ReactNode; + offline: ReactNode; isRowExpandable?: (record: RecordType) => boolean; isRowSelectable?: (record: RecordType) => boolean; loading: ReactNode; diff --git a/packages/ra-ui-materialui/src/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx new file mode 100644 index 00000000000..953fad29111 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { + Alert, + AlertProps, + ComponentsOverrides, + styled, + Typography, +} from '@mui/material'; +import { useGetResourceLabel, useResourceContext, useTranslate } from 'ra-core'; + +export const Offline = (props: Offline) => { + const { message: messageProp } = props; + const translate = useTranslate(); + 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 = translate( + messageProp ?? `resources.${resource}.navigation.offline`, + { + name: getResourceLabel(resource, 0), + _: + messageProp ?? + translate('ra.notification.offline', { + name: getResourceLabel(resource, 0), + _: 'No connectivity. Could not fetch data.', + }), + } + ); + return ( + + {message} + + ); +}; + +export interface Offline extends AlertProps { + resource?: string; + message?: string; +} + +const PREFIX = 'RaOffline'; + +const Root = styled(Alert, { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(() => ({})); + +declare module '@mui/material/styles' { + interface ComponentNameToClassKey { + RaOffline: 'root'; + } + + interface ComponentsPropsList { + RaOffline: Partial; + } + + interface Components { + RaOffline?: { + defaultProps?: ComponentsPropsList['RaOffline']; + styleOverrides?: ComponentsOverrides< + Omit + >['RaOffline']; + }; + } +} diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx index 532fc1c7e03..183d1f2c255 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import fakeRestProvider from 'ra-data-fakerest'; -import { CardContent } from '@mui/material'; +import { CardContent, Typography } from '@mui/material'; import { ResourceDefinitionContextProvider } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; @@ -26,7 +26,10 @@ const fakeData = { { id: 8, name: 'Charlie Watts' }, ], }; -const dataProvider = fakeRestProvider(fakeData, false); +const dataProvider = fakeRestProvider( + fakeData, + process.env.NODE_ENV !== 'test' +); const resouceDefs = { artists: { @@ -69,6 +72,64 @@ export const Children = () => ( ); +const LoadChildrenOnDemand = ({ children }: { children: React.ReactNode }) => { + const [showChildren, setShowChildren] = React.useState(false); + const handleClick = () => { + setShowChildren(true); + }; + return showChildren ? ( + children + ) : ( +
+ + Don't forget to go offline first + + +
+ ); +}; + +export const Offline = () => ( + + + + + + + + + + + + +); + +export const OfflineWithChildren = () => ( + + + + + + + + + + + + + + + + + +); + const fakeDataWidthDifferentIdTypes = { bands: [{ id: 1, name: 'band_1', members: [1, '2', '3'] }], artists: [ diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx index 01e184df22f..c92f9f8643c 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx @@ -149,11 +149,11 @@ export const ReferenceArrayFieldView = ( props: ReferenceArrayFieldViewProps ) => { const { children, pagination, className, sx } = props; - const { isPending, total } = useListContext(); + const { isPending, isPaused, total } = useListContext(); return ( - {isPending ? ( + {isPending && !isPaused ? ( diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 31f3bed3cde..ee5cf5ac9b9 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -138,10 +138,7 @@ const data = { const defaultDataProvider = fakeRestDataProvider(data); const BookList = () => { - const { error, isPending } = useListContext(); - if (isPending) { - return
Loading...
; - } + const { error } = useListContext(); if (error) { return
Error: {error.message}
; } diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index 9ebb04efeb7..c93f8f9c64e 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -78,21 +78,22 @@ export const ListView = ( empty !== false &&
{empty}
; const shouldRenderEmptyPage = - (isPaused && isPlaceholderData) || - (!error && - // the list is not loading data for the first time - !isPending && - // the API returned no data (using either normal or partial pagination) - (total === 0 || - (total == null && - hasPreviousPage === false && - hasNextPage === false && - // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it - data.length === 0)) && - // the user didn't set any filters - !Object.keys(filterValues).length && - // there is an empty page component - empty !== false); + !isPaused && + !isPlaceholderData && + !error && + // the list is not loading data for the first time + !isPending && + // the API returned no data (using either normal or partial pagination) + (total === 0 || + (total == null && + hasPreviousPage === false && + hasNextPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + data.length === 0)) && + // the user didn't set any filters + !Object.keys(filterValues).length && + // there is an empty page component + empty !== false; return ( diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index 8dc4c24cbb5..312de3a127f 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -19,7 +19,7 @@ import { useTranslate, } from 'ra-core'; import * as React from 'react'; -import { isValidElement, type ReactElement } from 'react'; +import { isValidElement, type ReactNode } from 'react'; import { ListNoResults } from '../ListNoResults'; import { SimpleListLoading } from './SimpleListLoading'; @@ -29,6 +29,7 @@ import { SimpleListItem, type SimpleListItemProps, } from './SimpleListItem'; +import { Offline } from '../../Offline'; /** * The component renders a list of records as a Material UI . @@ -76,6 +77,7 @@ export const SimpleList = ( leftAvatar, leftIcon, linkType, + offline = DefaultOffline, rowClick, primaryText, rightAvatar, @@ -91,7 +93,7 @@ export const SimpleList = ( const { data, isPaused, isPending, isPlaceholderData, total } = useListContextWithProps(props); - if (isPending === true) { + if (isPending === true && !isPaused) { return ( ( } if ( - data == null || - data.length === 0 || - total === 0 || - (isPaused && isPlaceholderData) + (data == null || data.length === 0 || total === 0) && + !isPaused && + !isPlaceholderData ) { if (empty) { return empty; @@ -116,9 +117,17 @@ export const SimpleList = ( return null; } + if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (offline) { + return offline; + } + + return null; + } + return ( - {data.map((record, rowIndex) => ( + {data?.map((record, rowIndex) => ( extends SimpleListBaseProps, Omit { className?: string; - empty?: ReactElement; + empty?: ReactNode; + offline?: ReactNode; hasBulkActions?: boolean; // can be injected when using the component without context resource?: string; @@ -281,6 +291,7 @@ const Root = styled(List, { }); const DefaultEmpty = ; +const DefaultOffline = ; declare module '@mui/material/styles' { interface ComponentNameToClassKey { diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx index b990b1666db..0d308657a71 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx @@ -5,6 +5,7 @@ import { ResourceDefinitionContextProvider, useList, TestMemoryRouter, + ListControllerResult, } from 'ra-core'; import { Typography, Divider as MuiDivider } from '@mui/material'; @@ -29,11 +30,14 @@ export default { const Wrapper = ({ children, - data = [bookGenres[2], bookGenres[4], bookGenres[1]], + listContext = { + data: [bookGenres[2], bookGenres[4], bookGenres[1]], + }, +}: { + children: React.ReactNode; + listContext?: Partial; }) => { - const listContextValue = useList({ - data, - }); + const listContextValue = useList(listContext); return ( ( ); export const NoData = () => ( - + ); export const Empty = ({ listContext = { data: [] } }) => ( - + No genres} /> - + ); export const Loading = () => ( - + + + +); + +export const Offline = () => ( + - +
); export const Direction = () => ( diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index aa5bdda3d6a..90ca75ea8a9 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -19,6 +19,7 @@ import { import { LinearProgress } from '../layout/LinearProgress'; import { Link } from '../Link'; +import { Offline } from '../Offline'; /** * Iterator component to be used to display a list of entities, using a single field @@ -63,6 +64,7 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { className, children, empty, + offline = DefaultOffline, linkType = 'edit', gap = 1, direction = 'row', @@ -73,15 +75,14 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { const resource = useResourceContext(props); const createPath = useCreatePath(); - if (isPending === true) { + if (isPending && !isPaused) { return ; } if ( - data == null || - data.length === 0 || - total === 0 || - (isPaused && isPlaceholderData) + (data == null || data.length === 0 || total === 0) && + !isPaused && + !isPlaceholderData ) { if (empty) { return empty; @@ -89,6 +90,13 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { return null; } + if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (offline) { + return offline; + } + + return null; + } return ( { className={className} {...sanitizeListRestProps(rest)} > - {data.map((record, rowIndex) => { + {data?.map((record, rowIndex) => { const resourceLinkPath = !linkType ? false : createPath({ @@ -141,7 +149,8 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { export interface SingleFieldListProps extends StackProps { className?: string; - empty?: React.ReactElement; + empty?: React.ReactNode; + offline?: React.ReactNode; linkType?: string | false; children?: React.ReactNode; // can be injected when using the component without context @@ -199,3 +208,5 @@ declare module '@mui/material/styles' { }; } } + +const DefaultOffline = ; diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index ac65742ef1c..66920d7f233 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -39,6 +39,7 @@ import { import { BulkActionsToolbar } from '../BulkActionsToolbar'; import { BulkDeleteButton } from '../../button'; import { ListNoResults } from '../ListNoResults'; +import { Offline } from '../../Offline'; const defaultBulkActionButtons = ; @@ -146,6 +147,7 @@ export const Datagrid: React.ForwardRefExoticComponent< hover, isRowSelectable, isRowExpandable, + offline = DefaultOffline, resource, rowClick, rowSx, @@ -219,7 +221,7 @@ export const Datagrid: React.ForwardRefExoticComponent< [data, isRowSelectable, onSelect, onToggleItem, selectedIds] ); - if (isPending === true) { + if (isPending && !isPaused) { return ( */ empty?: ReactElement; + /** + * The component used to render the empty table when user are offline. + * + * @see https://marmelab.com/react-admin/Datagrid.html#offline + * @example + * import { List, Datagrid } from 'react-admin'; + * + * const CustomOffline = () =>
We couldn't fetch book as you are offline
; + * + * const PostList = () => ( + * + * }> + * ... + * + * + * ); + */ + offline?: ReactElement; + /** * A function that returns whether the row for a record is expandable. * @@ -617,3 +644,4 @@ const sanitizeRestProps = props => Datagrid.displayName = 'Datagrid'; const DefaultEmpty = ; +const DefaultOffline = ; diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx index 91c7c7059f5..90f4a62f21a 100644 --- a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx @@ -38,8 +38,10 @@ import { DataTableColumn } from './DataTableColumn'; import { DataTableNumberColumn } from './DataTableNumberColumn'; import { ColumnsSelector } from './ColumnsSelector'; import { DataTableRowSxContext } from './DataTableRowSxContext'; +import { Offline } from '../../Offline'; const DefaultEmpty = ; +const DefaultOffline = ; const DefaultFoot = (_props: { children: ReactNode }) => null; const PREFIX = 'RaDataTable'; @@ -149,6 +151,7 @@ export const DataTable = React.forwardRef(function DataTable< children, className, empty = DefaultEmpty, + offline = DefaultOffline, expand, bulkActionsToolbar, bulkActionButtons = canDelete ? defaultBulkActionButtons : false, @@ -184,6 +187,7 @@ export const DataTable = React.forwardRef(function DataTable< hasBulkActions={hasBulkActions} loading={loading} empty={empty} + offline={offline} > */ empty?: ReactNode; + /** + * The component used to render the offline table. + * + * @see https://marmelab.com/react-admin/DataTable.html#offline + * @example + * import { List, DataTable } from 'react-admin'; + * + * const CustomOffline = () =>
No books found
; + * + * const PostList = () => ( + * + * }> + * ... + * + * + * ); + */ + offline?: ReactNode; + /** * A function that returns whether the row for a record is expandable. * diff --git a/packages/ra-ui-materialui/src/list/index.ts b/packages/ra-ui-materialui/src/list/index.ts index 1dbfc6a79fc..24a0cd39895 100644 --- a/packages/ra-ui-materialui/src/list/index.ts +++ b/packages/ra-ui-materialui/src/list/index.ts @@ -13,6 +13,7 @@ export * from './ListGuesser'; export * from './ListNoResults'; export * from './ListToolbar'; export * from './ListView'; +export * from '../Offline'; export * from './pagination'; export * from './Placeholder'; export * from './SimpleList'; From 8f3096b423c552763d630924b20f68a7ec9100e2 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 11:21:45 +0200 Subject: [PATCH 20/58] Add offline support to reference components --- .../src/field/ReferenceManyCount.tsx | 43 +++++++++++++------ .../src/field/ReferenceOneField.tsx | 35 ++++++++++++--- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx index 617d6cf2b0a..3faf69cb7b4 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { useReferenceManyFieldController, useRecordContext, @@ -37,6 +37,7 @@ export const ReferenceManyCount = ( filter, sort, link, + offline, resource, source = 'id', timeout = 1000, @@ -46,7 +47,7 @@ export const ReferenceManyCount = ( const oneSecondHasPassed = useTimeout(timeout); const createPath = useCreatePath(); - const { isPending, error, total } = + const { isPaused, isPending, error, total } = useReferenceManyFieldController({ filter, sort, @@ -54,23 +55,36 @@ export const ReferenceManyCount = ( perPage: 1, record, reference, - // @ts-ignore remove when #8491 is released resource, source, target, }); - const body = isPending ? ( - oneSecondHasPassed ? ( - - ) : ( - '' - ) - ) : error ? ( - - ) : ( - total - ); + let body: ReactNode = total; + + if (isPaused && total == null) { + body = offline ?? ( + + ); + } + + if (isPending && !isPaused && oneSecondHasPassed) { + body = ; + } + + if (error) { + body = ( + + ); + } return link && record ? ( extends Omit, 'source'>, Omit { + offline?: ReactNode; reference: string; source?: string; target: string; diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index 35ad0265729..476bbc2fd7e 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, ReactNode, useMemo } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { UseQueryOptions } from '@tanstack/react-query'; import { Typography } from '@mui/material'; import { @@ -40,6 +40,7 @@ export const ReferenceOneField = < source = 'id', target, emptyText, + offline: offlineProp = 'ra-references.single_offline', sort, filter, link, @@ -83,11 +84,32 @@ export const ReferenceOneField = < emptyText ) : null; - return !record || + const offline = + typeof offlineProp === 'string' ? ( + + {offlineProp && translate(offlineProp, { _: offlineProp })} + + ) : offlineProp ? ( + offlineProp + ) : null; + + if ( + !record || (!controllerProps.isPending && - controllerProps.referenceRecord == null) ? ( - empty - ) : ( + !controllerProps.isPaused && + controllerProps.referenceRecord == null) + ) { + return empty; + } + + if ( + !record || + (controllerProps.isPaused && controllerProps.referenceRecord == null) + ) { + return offline; + } + + return ( @@ -111,7 +133,8 @@ export interface ReferenceOneFieldProps< source?: string; filter?: any; link?: LinkToType; - emptyText?: string | ReactElement; + emptyText?: ReactNode; + offline?: ReactNode; queryOptions?: Omit< UseQueryOptions<{ data: ReferenceRecordType[]; From 9f418bff81eec6a4d17f7779cddf952b085f36eb Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 11:21:55 +0200 Subject: [PATCH 21/58] Add offline support to details components --- .../src/controller/edit/useEditController.ts | 3 + .../field/useReferenceOneFieldController.tsx | 78 +++++++++++-------- .../src/controller/show/useShowController.ts | 3 + .../ra-ui-materialui/src/detail/EditView.tsx | 36 +++++++-- .../ra-ui-materialui/src/detail/ShowView.tsx | 35 +++++++-- .../ra-ui-materialui/src/layout/Title.tsx | 4 +- 6 files changed, 113 insertions(+), 46 deletions(-) diff --git a/packages/ra-core/src/controller/edit/useEditController.ts b/packages/ra-core/src/controller/edit/useEditController.ts index 3ed29befbbc..c53e7436eb3 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -108,6 +108,7 @@ export const useEditController = < error, isLoading, isFetching, + isPaused, isPending, refetch, } = useGetOne( @@ -268,6 +269,7 @@ export const useEditController = < error, isFetching, isLoading, + isPaused, isPending, mutationMode, record, @@ -303,6 +305,7 @@ export interface EditControllerBaseResult defaultTitle?: string; isFetching: boolean; isLoading: boolean; + isPaused: boolean; refetch: UseGetOneHookValue['refetch']; redirect: RedirectionSideEffect; resource: string; diff --git a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx index b4ab292201d..4bac19e1733 100644 --- a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx +++ b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx @@ -69,40 +69,48 @@ 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, + isPending, + isPaused, + 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, @@ -110,6 +118,8 @@ export const useReferenceOneFieldController = < isFetching, isLoading, isPending, + isPaused, + isPlaceholderData, refetch, }; }; diff --git a/packages/ra-core/src/controller/show/useShowController.ts b/packages/ra-core/src/controller/show/useShowController.ts index 0138090e6da..42a361f265f 100644 --- a/packages/ra-core/src/controller/show/useShowController.ts +++ b/packages/ra-core/src/controller/show/useShowController.ts @@ -96,6 +96,7 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, refetch, } = useGetOne( @@ -141,6 +142,7 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, record, refetch, @@ -162,6 +164,7 @@ export interface ShowControllerBaseResult { defaultTitle?: string; isFetching: boolean; isLoading: boolean; + isPaused: boolean; resource: string; record?: RecordType; refetch: UseGetOneHookValue['refetch']; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index c40ef18c952..57b102b1e81 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ReactElement, ElementType } from 'react'; +import type { ReactNode, ElementType } from 'react'; import { Card, CardContent, @@ -14,8 +14,10 @@ import { useEditContext, useResourceDefinition } from 'ra-core'; import { EditActions } from './EditActions'; import { Title } from '../layout'; +import { Offline } from '../Offline'; const defaultActions = ; +const defaultOffline = ; export const EditView = (inProps: EditViewProps) => { const props = useThemeProps({ @@ -28,13 +30,31 @@ export const EditView = (inProps: EditViewProps) => { children, className, component: Content = Card, + offline = defaultOffline, emptyWhileLoading = false, title, ...rest } = props; const { hasShow } = useResourceDefinition(); - const { resource, defaultTitle, record, isPending } = useEditContext(); + const { resource, defaultTitle, record, isPending, isPaused } = + useEditContext(); + + if (isPaused && offline) { + return ( + +
+ {offline} +
+
+ ); + } const finalActions = typeof actions === 'undefined' && hasShow ? defaultActions : actions; @@ -68,11 +88,12 @@ export const EditView = (inProps: EditViewProps) => { export interface EditViewProps extends Omit, 'id' | 'title'> { - actions?: ReactElement | false; - aside?: ReactElement; + actions?: ReactNode | false; + aside?: ReactNode; + offline?: ReactNode; component?: ElementType; emptyWhileLoading?: boolean; - title?: string | ReactElement | false; + title?: ReactNode; sx?: SxProps; } @@ -82,6 +103,7 @@ export const EditClasses = { main: `${PREFIX}-main`, noActions: `${PREFIX}-noActions`, card: `${PREFIX}-card`, + offline: `${PREFIX}-offline`, }; const Root = styled('div', { @@ -95,6 +117,10 @@ const Root = styled('div', { [`& .${EditClasses.noActions}`]: { marginTop: '1em', }, + [`& .${EditClasses.offline}`]: { + flexDirection: 'column', + alignItems: 'unset', + }, [`& .${EditClasses.card}`]: { flex: '1 1 auto', }, diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 5e8d02d35a6..35c5bb04635 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 } from 'react'; +import type { ReactNode, ElementType } from 'react'; import { Card, type ComponentsOverrides, @@ -12,8 +12,10 @@ import clsx from 'clsx'; import { useShowContext, useResourceDefinition } from 'ra-core'; import { ShowActions } from './ShowActions'; import { Title } from '../layout'; +import { Offline } from '../Offline'; const defaultActions = ; +const defaultOffline = ; export const ShowView = (inProps: ShowViewProps) => { const props = useThemeProps({ @@ -27,13 +29,30 @@ export const ShowView = (inProps: ShowViewProps) => { className, component: Content = Card, emptyWhileLoading = false, + offline = defaultOffline, title, ...rest } = props; - const { resource, defaultTitle, record } = useShowContext(); + const { resource, defaultTitle, isPaused, record } = useShowContext(); const { hasEdit } = useResourceDefinition(); + if (isPaused && offline) { + return ( + +
+ {offline} +
+
+ ); + } + const finalActions = typeof actions === 'undefined' && hasEdit ? defaultActions : actions; @@ -64,11 +83,12 @@ export const ShowView = (inProps: ShowViewProps) => { export interface ShowViewProps extends Omit, 'id' | 'title'> { - actions?: ReactElement | false; - aside?: ReactElement; + actions?: ReactNode; + aside?: ReactNode; component?: ElementType; emptyWhileLoading?: boolean; - title?: string | ReactElement | false; + offline?: ReactNode; + title?: ReactNode; sx?: SxProps; } @@ -78,6 +98,7 @@ export const ShowClasses = { main: `${PREFIX}-main`, noActions: `${PREFIX}-noActions`, card: `${PREFIX}-card`, + offline: `${PREFIX}-offline`, }; const Root = styled('div', { @@ -90,6 +111,10 @@ const Root = styled('div', { [`& .${ShowClasses.noActions}`]: { marginTop: '1em', }, + [`& .${ShowClasses.offline}`]: { + flexDirection: 'column', + alignItems: 'unset', + }, [`& .${ShowClasses.card}`]: { flex: '1 1 auto', }, diff --git a/packages/ra-ui-materialui/src/layout/Title.tsx b/packages/ra-ui-materialui/src/layout/Title.tsx index 057d9849a1e..d8c8e8a928e 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, ReactNode } 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?: ReactNode; preferenceKey?: string | false; } From 6778fd1f4d44113abd899eb49e7fe4d0c0787212 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 11:53:40 +0200 Subject: [PATCH 22/58] Fix tests and stories --- .../src/controller/useReference.spec.tsx | 8 ++++++ .../src/field/ReferenceField.spec.tsx | 26 +++---------------- .../src/field/ReferenceField.tsx | 22 +++++++++------- .../src/field/ReferenceManyCount.spec.tsx | 2 +- .../src/field/ReferenceManyCount.tsx | 25 +++++++++++++----- .../InPlaceEditor/InPlaceEditor.spec.tsx | 4 +++ 6 files changed, 48 insertions(+), 39 deletions(-) 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-ui-materialui/src/field/ReferenceField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx index 22e6c48c13c..8b8a03979d2 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx @@ -25,6 +25,7 @@ import { SXLink, SXNoLink, SlowAccessControl, + ErrorWhileFetching, } from './ReferenceField.stories'; import { TextField } from './TextField'; @@ -459,29 +460,8 @@ describe('', () => { it('should display an error icon if the dataProvider call fails', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - const dataProvider = testDataProvider({ - getMany: jest.fn().mockRejectedValue(new Error('boo')), - }); - render( - - - - - - - - ); - await new Promise(resolve => setTimeout(resolve, 10)); - const ErrorIcon = screen.queryByRole('presentation', { - hidden: true, - }); - expect(ErrorIcon).not.toBeNull(); - await screen.findByText('boo'); + render(); + await screen.findByText('ra.notification.http_error'); }); describe('link', () => { diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index caff3d77d09..8e74378eb02 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -12,11 +12,11 @@ import ErrorIcon from '@mui/icons-material/Error'; import { type LinkToType, useGetRecordRepresentation, - useTranslate, type RaRecord, ReferenceFieldBase, useReferenceFieldContext, useFieldValue, + Translate, } from 'ra-core'; import type { UseQueryOptions } from '@tanstack/react-query'; import clsx from 'clsx'; @@ -68,13 +68,12 @@ export const ReferenceField = < name: PREFIX, }); const { emptyText } = props; - const translate = useTranslate(); const id = useFieldValue(props); if (id == null) { return emptyText ? ( - {emptyText && translate(emptyText, { _: emptyText })} + {emptyText} ) : null; } @@ -124,7 +123,6 @@ export const ReferenceFieldView = < useReferenceFieldContext(); const getRecordRepresentation = useGetRecordRepresentation(reference); - const translate = useTranslate(); if (error) { return ( @@ -135,9 +133,9 @@ export const ReferenceFieldView = < variant="body2" sx={{ color: 'error.main' }} > - {translate('ra.notification.http_error', { - _: 'Server communication error', - })} + + Server communication error + ); @@ -163,7 +161,13 @@ export const ReferenceFieldView = < variant="body2" sx={{ color: 'error.main' }} > - {offline && translate(offline, { _: offline })} + {typeof offline === 'string' ? ( + + {offline} + + ) : ( + offline + )} ); @@ -173,7 +177,7 @@ export const ReferenceFieldView = < } return emptyText ? ( - {emptyText && translate(emptyText, { _: emptyText })} + {emptyText} ) : null; } diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx index c951b4715a1..2dbde3c46f2 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx @@ -17,7 +17,7 @@ describe('', () => { it('should render an error icon when the request fails', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); - await screen.findByTitle('error'); + await screen.findByText('Server communication error'); }); it('should accept a filter prop', async () => { render(); diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx index 3faf69cb7b4..c0405078f1e 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx @@ -6,8 +6,14 @@ import { useCreatePath, SortPayload, RaRecord, + Translate, } from 'ra-core'; -import { Typography, TypographyProps, CircularProgress } from '@mui/material'; +import { + Typography, + TypographyProps, + CircularProgress, + Stack, +} from '@mui/material'; import ErrorIcon from '@mui/icons-material/Error'; import { FieldProps } from './types'; @@ -78,11 +84,18 @@ export const ReferenceManyCount = ( if (error) { body = ( - + + + + + Server communication error + + + ); } diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx index de4cc51d302..78aacf96e5b 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx @@ -4,6 +4,9 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { Basic } from './InPlaceEditor.stories'; describe('InPlaceEditor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('should render the field value on mount', async () => { render(); await screen.findByText('John Doe'); @@ -24,6 +27,7 @@ describe('InPlaceEditor', () => { await screen.findByText('Jane Doe'); }); it('should revert to the previous version on error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); render(); const value = await screen.findByText('John Doe'); value.click(); From 934fff456514476e90775e9430b361b76c0a3bbe Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 12:09:01 +0200 Subject: [PATCH 23/58] Use Offline everywhere --- .../src/field/ReferenceField.tsx | 31 +++---------------- .../src/field/ReferenceManyCount.tsx | 13 +++----- .../src/field/ReferenceOneField.tsx | 14 +++------ 3 files changed, 13 insertions(+), 45 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 8e74378eb02..1a12b899e29 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -25,6 +25,7 @@ import { LinearProgress } from '../layout'; import { Link } from '../Link'; import type { FieldProps } from './types'; import { genericMemo } from './genericMemo'; +import { Offline } from '../Offline'; /** * Fetch reference record, and render its representation, or delegate rendering to child component. @@ -104,6 +105,7 @@ export interface ReferenceFieldProps< // useful to prevent click bubbling in a datagrid with rowClick const stopPropagation = e => e.stopPropagation(); +const defaultOffline = ; export const ReferenceFieldView = < RecordType extends Record = Record, @@ -115,7 +117,7 @@ export const ReferenceFieldView = < children, className, emptyText, - offline = 'ra.notification.offline', + offline = defaultOffline, reference, sx, } = props; @@ -148,32 +150,7 @@ export const ReferenceFieldView = < } if (!referenceRecord) { if (isPaused) { - if (typeof offline === 'string') { - return ( - - - - {typeof offline === 'string' ? ( - - {offline} - - ) : ( - offline - )} - - - ); - } - // We either have a ReactNode, a boolean or null|undefined - return offline || null; + return offline; } return emptyText ? ( diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx index c0405078f1e..a31da69b352 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx @@ -19,6 +19,9 @@ import ErrorIcon from '@mui/icons-material/Error'; import { FieldProps } from './types'; import { sanitizeFieldRestProps } from './sanitizeFieldRestProps'; import { Link } from '../Link'; +import { Offline } from '../Offline'; + +const defaultOffline = ; /** * Fetch and render the number of records related to the current one @@ -43,7 +46,7 @@ export const ReferenceManyCount = ( filter, sort, link, - offline, + offline = defaultOffline, resource, source = 'id', timeout = 1000, @@ -69,13 +72,7 @@ export const ReferenceManyCount = ( let body: ReactNode = total; if (isPaused && total == null) { - body = offline ?? ( - - ); + body = offline; } if (isPending && !isPaused && oneSecondHasPassed) { diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index 476bbc2fd7e..0942da763db 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -17,6 +17,9 @@ import { import { FieldProps } from './types'; import { ReferenceFieldView } from './ReferenceField'; +import { Offline } from '../Offline'; + +const defaultOffline = ; /** * Render the related record in a one-to-one relationship @@ -40,7 +43,7 @@ export const ReferenceOneField = < source = 'id', target, emptyText, - offline: offlineProp = 'ra-references.single_offline', + offline = defaultOffline, sort, filter, link, @@ -84,15 +87,6 @@ export const ReferenceOneField = < emptyText ) : null; - const offline = - typeof offlineProp === 'string' ? ( - - {offlineProp && translate(offlineProp, { _: offlineProp })} - - ) : offlineProp ? ( - offlineProp - ) : null; - if ( !record || (!controllerProps.isPending && From 32af6175b887bcc9b72e1cdab0c8b5582de1de44 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 14:55:07 +0200 Subject: [PATCH 24/58] Add support for offline in Reference inputs --- .../controller/input/ReferenceInputBase.tsx | 6 ++- .../input/useReferenceArrayInputController.ts | 3 ++ .../input/useReferenceInputController.ts | 6 ++- .../src/form/choices/ChoicesContext.ts | 1 + .../src/input/ReferenceArrayInput.stories.tsx | 46 ++++++++++++++++++ .../src/input/ReferenceArrayInput.tsx | 16 +++++-- .../src/input/ReferenceInput.stories.tsx | 48 +++++++++++++++++++ .../src/input/ReferenceInput.tsx | 22 +++++++-- 8 files changed, 140 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceInputBase.tsx b/packages/ra-core/src/controller/input/ReferenceInputBase.tsx index d0425f2a18b..f9c2a668a54 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputBase.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputBase.tsx @@ -70,6 +70,7 @@ export const ReferenceInputBase = (props: ReferenceInputBaseProps) => { reference, sort = { field: 'id', order: 'DESC' }, filter = {}, + offline = null, } = props; const controllerProps = useReferenceInputController({ @@ -78,7 +79,9 @@ export const ReferenceInputBase = (props: ReferenceInputBaseProps) => { filter, }); - return ( + return controllerProps.isPaused ? ( + offline + ) : ( {children} @@ -91,4 +94,5 @@ export interface ReferenceInputBaseProps extends InputProps, UseReferenceInputControllerParams { children?: ReactNode; + offline?: ReactNode; } diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 4dd10aac617..1e18086911d 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -61,6 +61,7 @@ export const useReferenceArrayInputController = < error: errorGetMany, isLoading: isLoadingGetMany, isFetching: isFetchingGetMany, + isPaused: isPausedGetMany, isPending: isPendingGetMany, refetch: refetchGetMany, } = useGetManyAggregate( @@ -99,6 +100,7 @@ export const useReferenceArrayInputController = < error: errorGetList, isLoading: isLoadingGetList, isFetching: isFetchingGetList, + isPaused: isPausedGetList, isPending: isPendingGetList, refetch: refetchGetMatching, } = useGetList( @@ -153,6 +155,7 @@ export const useReferenceArrayInputController = < hideFilter: paramsModifiers.hideFilter, isFetching: isFetchingGetMany || isFetchingGetList, isLoading: isLoadingGetMany || isLoadingGetList, + isPaused: isPausedGetMany || isPausedGetList, isPending: isPendingGetMany || isPendingGetList, page: params.page, perPage: params.perPage, diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 7ddbced53d2..1bfbd19f2f8 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -84,6 +84,7 @@ export const useReferenceInputController = ( pageInfo, isFetching: isFetchingPossibleValues, isLoading: isLoadingPossibleValues, + isPaused: isPausedPossibleValues, isPending: isPendingPossibleValues, error: errorPossibleValues, refetch: refetchGetList, @@ -112,6 +113,7 @@ export const useReferenceInputController = ( error: errorReference, isLoading: isLoadingReference, isFetching: isFetchingReference, + isPaused: isPausedReference, isPending: isPendingReference, } = useReference({ id: currentValue, @@ -128,6 +130,7 @@ export const useReferenceInputController = ( // The reference query isn't enabled when there is no value yet but as it has no data, react-query will flag it as pending (currentValue != null && currentValue !== '' && isPendingReference) || isPendingPossibleValues; + const isPaused = isPausedPossibleValues || isPausedReference; // We need to delay the update of the referenceRecord and the finalData // to the next React state update, because otherwise it can raise a warning @@ -176,7 +179,8 @@ export const useReferenceInputController = ( hideFilter: paramsModifiers.hideFilter, isFetching: isFetchingReference || isFetchingPossibleValues, isLoading: isLoadingReference || isLoadingPossibleValues, - isPending: isPending, + isPaused, + isPending, page: params.page, perPage: params.perPage, refetch, diff --git a/packages/ra-core/src/form/choices/ChoicesContext.ts b/packages/ra-core/src/form/choices/ChoicesContext.ts index 6e0b53dee80..1821769559a 100644 --- a/packages/ra-core/src/form/choices/ChoicesContext.ts +++ b/packages/ra-core/src/form/choices/ChoicesContext.ts @@ -20,6 +20,7 @@ export type ChoicesContextBaseValue = { hideFilter: (filterName: string) => void; isFetching: boolean; isLoading: boolean; + isPaused: boolean; page: number; perPage: number; refetch: (() => void) | UseGetListHookValue['refetch']; diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx index 19830fb0f19..24f18ee9e59 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx @@ -19,6 +19,7 @@ import { ReferenceArrayInput } from './ReferenceArrayInput'; import { AutocompleteArrayInput } from './AutocompleteArrayInput'; import { SelectArrayInput } from './SelectArrayInput'; import { CheckboxGroupInput } from './CheckboxGroupInput'; +import { Typography } from '@mui/material'; export default { title: 'ra-ui-materialui/input/ReferenceArrayInput' }; @@ -74,6 +75,51 @@ export const Basic = () => ( ); +const LoadChildrenOnDemand = ({ children }: { children: React.ReactNode }) => { + const [showChildren, setShowChildren] = React.useState(false); + const handleClick = () => { + setShowChildren(true); + }; + return showChildren ? ( + children + ) : ( +
+ + Don't forget to go offline first + + +
+ ); +}; + +export const Offline = () => ( + + + + ( + + + + + + + + )} + /> + + +); + export const WithAutocompleteInput = () => ( { reference, sort, filter = defaultFilter, + offline, } = props; if (React.Children.count(children) !== 1) { throw new Error( @@ -95,7 +98,13 @@ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { filter, }); - return ( + return controllerProps.isPaused ? ( + offline ?? ( + + + + ) + ) : ( {children} @@ -110,7 +119,8 @@ const defaultFilter = {}; export interface ReferenceArrayInputProps extends InputProps, UseReferenceArrayInputParams { - children?: ReactElement; + children?: ReactNode; label?: string; + offline?: ReactNode; [key: string]: any; } diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx index fda31da9ad9..cc2c6472adb 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx @@ -109,6 +109,54 @@ export const Basic = ({ dataProvider = dataProviderWithAuthors }) => ( ); +const LoadChildrenOnDemand = ({ children }: { children: React.ReactNode }) => { + const [showChildren, setShowChildren] = React.useState(false); + const handleClick = () => { + setShowChildren(true); + }; + return showChildren ? ( + children + ) : ( +
+ + Don't forget to go offline first + + +
+ ); +}; + +const BookEditOffline = () => ( + { + console.log(data); + }, + }} + > + + + + + + +); + +export const Offline = ({ dataProvider = dataProviderWithAuthors }) => ( + + + + `${record.first_name} ${record.last_name}` + } + /> + + + +); + const tags = [ { id: 5, name: 'lorem' }, { id: 6, name: 'ipsum' }, diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index 0394f49622c..81664d4ce38 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { ReferenceInputBase, ReferenceInputBaseProps } from 'ra-core'; import { AutocompleteInput } from './AutocompleteInput'; +import { Offline } from '../Offline'; +import { Labeled } from '../Labeled'; /** * An Input component for choosing a reference record. Useful for foreign keys. @@ -64,7 +66,7 @@ import { AutocompleteInput } from './AutocompleteInput'; * a `setFilters` function. You can call this function to filter the results. */ export const ReferenceInput = (props: ReferenceInputProps) => { - const { children = defaultChildren, ...rest } = props; + const { children = defaultChildren, offline, ...rest } = props; if (props.validate && process.env.NODE_ENV !== 'production') { throw new Error( @@ -72,7 +74,20 @@ export const ReferenceInput = (props: ReferenceInputProps) => { ); } - return {children}; + return ( + + + + ) + } + > + {children} + + ); }; const defaultChildren = ; @@ -82,5 +97,6 @@ export interface ReferenceInputProps extends ReferenceInputBaseProps { * Call validate on the child component instead */ validate?: never; + offline?: ReactNode; [key: string]: any; } From 02a957a8ed80d538aaab253972ce16999bfbdc93 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 16:18:26 +0200 Subject: [PATCH 25/58] Add documentation --- docs/DataTable.md | 35 ++++++++++++++++++- docs/Datagrid.md | 21 ++++++++++++ docs/Edit.md | 67 +++++++++++++++++++++++++++---------- docs/ReferenceArrayInput.md | 26 ++++++++++++++ docs/ReferenceField.md | 26 ++++++++++++++ docs/ReferenceInput.md | 26 ++++++++++++++ docs/ReferenceManyCount.md | 29 +++++++++++++++- docs/ReferenceOneField.md | 42 +++++++++++++++++++---- docs/Show.md | 31 +++++++++++++++++ 9 files changed, 277 insertions(+), 26 deletions(-) diff --git a/docs/DataTable.md b/docs/DataTable.md index a1e41f82e0b..0a02e153bfb 100644 --- a/docs/DataTable.md +++ b/docs/DataTable.md @@ -53,12 +53,13 @@ Each `` defines one column of the table: its `source` (used for s | `empty` | Optional | Element | `` | The component to render when the list is empty. | | `expand` | Optional | Element | | The component rendering the expand panel for each row. | | `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. | -| `head` | Optional | Element | `` | The component rendering the table header. | +| `head` | Optional | Element | `` | The component rendering the table header. | | `hiddenColumns` | Optional | Array | `[]` | The list of columns to hide by default. | | `foot` | Optional | Element | | The component rendering the table footer. | | `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. | | `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. | | `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. | +| `offline` | Optional | Element | `` | The content rendered to render when data could not be fetched because of connectivity issues. | | `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. | | `rowSx` | Optional | Function | | A function that returns the `sx` prop to apply to a row. | | `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. | @@ -754,6 +755,38 @@ export const PostList = () => ( ``` {% endraw %} +## `offline` + +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```tsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +const BookList = () => ( + + }> + ... + + +); +``` + ## `rowClick` By default, `` uses the current [resource definition](https://marmelab.com/react-admin/Resource.html) to determine what to do when the user clicks on a row. If the resource has a `show` page, a row click redirects to the Show view. If the resource has an `edit` page, a row click redirects to the Edit view. Otherwise, the row is not clickable. diff --git a/docs/Datagrid.md b/docs/Datagrid.md index 92e1907c2b8..9d26fd75edc 100644 --- a/docs/Datagrid.md +++ b/docs/Datagrid.md @@ -57,6 +57,7 @@ Both are [Enterprise Edition](https://react-admin-ee.marmelab.com) components. | `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. | | `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. | | `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. | +| `offline` | Optional | Element | `` | The content rendered to render when data could not be fetched because of connectivity issues. | | `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. | | `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. | | `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. | @@ -640,6 +641,26 @@ export const PostList = () => ( ``` {% endraw %} +## `offline` + +It's possible that a Datagrid will have no records to display because of connectivity issues. In that case, the Datagrid will display a message indicating data couldn't be fetched. This message is translatable and its key is `ra.notification.offline`. + +You can customize the content to display by passing a component to the `offline` prop: + +```tsx +const CustomOffline = () =>
No network. Data couldn't be fetched.
; + +const PostList = () => ( + + }> + + + + + +); +``` + ## `optimized` When displaying large pages of data, you might experience some performance issues. diff --git a/docs/Edit.md b/docs/Edit.md index 7b44f9906ce..47d6cbfb023 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -60,22 +60,25 @@ export default App; You can customize the `` component using the following props: -* [`actions`](#actions): override the actions toolbar with a custom component -* [`aside`](#aside): component to render aside to the main content -* `children`: the components that renders the form -* `className`: passed to the root component -* [`component`](#component): override the root component -* [`disableAuthentication`](#disableauthentication): disable the authentication check -* [`emptyWhileLoading`](#emptywhileloading): Set to `true` to return `null` while the edit is loading. -* [`id`](#id): the id of the record to edit -* [`mutationMode`](#mutationmode): switch to optimistic or pessimistic mutations (undoable by default) -* [`mutationOptions`](#mutationoptions): options for the `dataProvider.update()` call -* [`queryOptions`](#queryoptions): options for the `dataProvider.getOne()` call -* [`redirect`](#redirect): change the redirect location after successful creation -* [`resource`](#resource): override the name of the resource to create -* [`sx`](#sx-css-api): Override the styles -* [`title`](#title): override the page title -* [`transform`](#transform): transform the form data before calling `dataProvider.update()` +| Prop | Required | Type | Default | Description | +| ----------------------- | -------- | ------------------------------------- | --------------------- | ------------------------------------------------------------- | +| `actions` | | `ReactNode` | | override the actions toolbar with a custom component | +| `aside` | | `ReactNode` | | component to render aside to the main content | +| `children` | | `ReactNode` | | The components that renders the form | +| `className` | | `string` | | passed to the root component | +| `component` | | `Component` | | override the root component | +| `disableAuthentication` | | `boolean` | | disable the authentication check | +| `emptyWhileLoading` | | `boolean` | | Set to `true` to return `null` while the edit is loading. | +| `id` | | `string | number` | | the id of the record to edit | +| `mutationMode` | | `pessimistic | optimistic | undoable` | | switch to optimistic or pessimistic mutations (undoable by default) | +| `mutationOptions` | | `object` | | options for the `dataProvider.update()` call | +| `offline` | | `ReactNode` | | The content rendered to render when data could not be fetched because of connectivity issues | +| `queryOptions` | | `object` | | options for the `dataProvider.getOne()` call | +| `redirect` | | `string | Function | false` | | change the redirect location after successful creation | +| `resource` | | `string` | | override the name of the resource to create | +| `sx` | | `object` | | Override the styles | +| `title` | | `ReactNode` | | override the page title | +| `transform` | | `Function` | | transform the form data before calling `dataProvider.update()` | ## `actions` @@ -488,6 +491,36 @@ The default `onError` function is: **Tip**: If you want to have different failure side effects based on the button clicked by the user, you can set the `mutationOptions` prop on the `` component, too. +## `offline` + +It's possible that a `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```tsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +const BookEdit = () => ( + }> + ... + +); +``` + ## `queryOptions` `` calls `dataProvider.getOne()` on mount via react-query's `useQuery` hook. You can customize the options you pass to this hook by setting the `queryOptions` prop. @@ -498,7 +531,7 @@ This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) t ```jsx import { Edit, SimpleForm } from 'react-admin'; -export const PostShow = () => ( +export const PostEdit = () => ( ... diff --git a/docs/ReferenceArrayInput.md b/docs/ReferenceArrayInput.md index 7bba916bc8d..fc91b975beb 100644 --- a/docs/ReferenceArrayInput.md +++ b/docs/ReferenceArrayInput.md @@ -104,6 +104,7 @@ See the [`children`](#children) section for more details. | `enableGet Choices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | | `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | | `label` | Optional | `string` | - | Useful only when `ReferenceArrayInput` is in a Filter array, the label is used as the Filter label. | +| `offline ` | Optional | `ReactNode` | - | The content rendered to render when data could not be fetched because of connectivity issues | | `page` | Optional | `number` | 1 | The current page number | | `perPage` | Optional | `number` | 25 | Number of suggestions to show | | `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | @@ -216,6 +217,31 @@ const filters = [ ]; ``` +## `offline` + +`` can display a custom message when data cannot be fetched because of connectivity issues. +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```jsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +} /> +``` + ## `parse` By default, children of `` transform the empty form value (an empty string) into `null` before passing it to the `dataProvider`. diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index 484588fe130..bd6c0ac6a68 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -77,6 +77,7 @@ It uses `dataProvider.getMany()` instead of `dataProvider.getOne()` [for perform | `emptyText` | Optional | `string` | '' | Defines a text to be shown 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` | - | The content rendered to render when data could not be fetched because of connectivity issues | | `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 | @@ -142,6 +143,31 @@ You can also use a custom `link` function to get a custom path for the children. /> ``` +## `offline` + +`` can display a custom message when data cannot be fetched because of connectivity issues. +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```jsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +} /> +``` + ## `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/ReferenceInput.md b/docs/ReferenceInput.md index 16e2db18f2e..3bc53e1ecb3 100644 --- a/docs/ReferenceInput.md +++ b/docs/ReferenceInput.md @@ -107,6 +107,7 @@ See the [`children`](#children) section for more details. | `enableGet Choices` | Optional | `({q: string}) => boolean` | `() => true` | Function taking the `filterValues` and returning a boolean to enable the `getList` call. | | `filter` | Optional | `Object` | `{}` | Permanent filters to use for getting the suggestion list | | `label` | Optional | `string` | - | Useful only when `ReferenceInput` is in a Filter array, the label is used as the Filter label. | +| `offline ` | Optional | `ReactNode` | - | The content rendered to render when data could not be fetched because of connectivity issues | | `page` | Optional | `number` | 1 | The current page number | | `perPage` | Optional | `number` | 25 | Number of suggestions to show | | `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | @@ -190,6 +191,31 @@ const filters = [ ]; ``` +## `offline` + +`` can display a custom message when data cannot be fetched because of connectivity issues. +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```jsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +} /> +``` + ## `parse` By default, children of `` transform the empty form value (an empty string) into `null` before passing it to the `dataProvider`. diff --git a/docs/ReferenceManyCount.md b/docs/ReferenceManyCount.md index bfc0d21f8b9..7f799c8c2f1 100644 --- a/docs/ReferenceManyCount.md +++ b/docs/ReferenceManyCount.md @@ -70,8 +70,9 @@ export const PostList = () => ( | `target` | Required | string | - | Name of the field in the related resource that points to the current one. | | `filter` | Optional | Object | - | Filter to apply to the query. | | `link` | Optional | bool | `false` | If true, the count is wrapped in a `` to the filtered list view. | +| `offline ` | Optional | `ReactNode` | - | The content rendered to render when data could not be fetched because of connectivity issues | | `resource` | Optional | string | - | Resource to count. Default to the current `ResourceContext` | -| `sort` | Optional | `{ field: string, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | The sort option sent to `getManyReference` | +| `sort` | Optional | `{ field: string, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | The sort option sent to `getManyReference` | | `timeout` | Optional | number | 1000 | Number of milliseconds to wait before displaying the loading indicator. | `` also accepts the [common field props](./Fields.md#common-field-props). @@ -120,6 +121,32 @@ When used in conjunction to the `filter` prop, the link will point to the list v ``` {% endraw %} + +## `offline` + +`` can display a custom message when data cannot be fetched because of connectivity issues. +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```jsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +} /> +``` + ## `reference` The `reference` prop is required and must be the name of the related resource to fetch. For instance, to fetch the number of comments related to the current post: diff --git a/docs/ReferenceOneField.md b/docs/ReferenceOneField.md index d0a3547c7d9..5afbc1d534c 100644 --- a/docs/ReferenceOneField.md +++ b/docs/ReferenceOneField.md @@ -55,14 +55,15 @@ const BookShow = () => ( ## Props | Prop | Required | Type | Default | Description | -| -------------- | -------- | ------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------- | -| `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'book_details' | -| `target` | Required | string | - | Target field carrying the relationship on the referenced resource, e.g. 'book_id' | -| `children` | Optional | `Element` | - | The Field element used to render the referenced record | -| `filter` | Optional | `Object` | `{}` | Used to filter referenced records | -| `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | +| -------------- | -------- | ------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------- | +| `reference` | Required | `string` | - | The name of the resource for the referenced records, e.g. 'book_details' | +| `target` | Required | string | - | Target field carrying the relationship on the referenced resource, e.g. 'book_id' | +| `children` | Optional | `Element` | - | The Field element used to render the referenced record | +| `filter` | Optional | `Object` | `{}` | Used to filter referenced records | +| `link` | Optional | `string | Function` | `edit` | Target of the link wrapping the rendered child. Set to `false` to disable the link. | +| `offline ` | Optional | `ReactNode` | - | The content rendered to render when data could not be fetched because of connectivity issues | | `queryOptions` | Optional | [`UseQueryOptions`](https://tanstack.com/query/v5/docs/react/reference/useQuery) | `{}` | `react-query` client options | -| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'ASC' }` | Used to order referenced records | +| `sort` | Optional | `{ field: String, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'ASC' }` | Used to order referenced records | `` also accepts the [common field props](./Fields.md#common-field-props). @@ -151,6 +152,33 @@ You can also set the `link` prop to a string, which will be used as the link typ ``` +## `offline` + +`` can display a custom message when data cannot be fetched because of connectivity issues. +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```jsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +}> + + +``` + ## `queryOptions` `` uses `react-query` to fetch the related record. You can set [any of `useQuery` options](https://tanstack.com/query/v5/docs/react/reference/useQuery) via the `queryOptions` prop. diff --git a/docs/Show.md b/docs/Show.md index dd735632b87..3a806f3f3bd 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -68,6 +68,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 content rendered to render when data could not be fetched because of connectivity issues | `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 @@ -276,6 +277,36 @@ 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` + +It's possible that a `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```tsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +const BookShow = () => ( + }> + ... + +); +``` + ## `queryOptions` `` accepts a `queryOptions` prop to pass options to the react-query client. From 4c713752e5279955f58dcd27b33c49f98ac244d0 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 16:48:45 +0200 Subject: [PATCH 26/58] Fix documentation --- docs/DataProviders.md | 6 +++--- docs/ReferenceArrayInput.md | 2 +- docs/ReferenceField.md | 2 +- docs/ReferenceInput.md | 2 +- docs/ReferenceManyCount.md | 2 +- docs/ReferenceOneField.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 96dd9bab28e..1ef257fa281 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -903,7 +903,7 @@ import { dataProvider } from './dataProvider'; export const queryClient = new QueryClient(); -addOfflineSupportToQueryClient({ +const queryClientWithOfflineSupport = addOfflineSupportToQueryClient({ queryClient, dataProvider, resources: ['posts', 'comments'], @@ -996,9 +996,9 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; import { dataProvider } from './dataProvider'; -export const queryClient = new QueryClient(); +const baseQueryClient = new QueryClient(); -addOfflineSupportToQueryClient({ +export const queryClient = addOfflineSupportToQueryClient({ queryClient, dataProvider, resources: ['posts', 'comments'], diff --git a/docs/ReferenceArrayInput.md b/docs/ReferenceArrayInput.md index fc91b975beb..6e5dbf23ef4 100644 --- a/docs/ReferenceArrayInput.md +++ b/docs/ReferenceArrayInput.md @@ -219,7 +219,7 @@ const filters = [ ## `offline` -`` can display a custom message when data cannot be fetched because of connectivity issues. +`` displays a message when data cannot be fetched because of connectivity issues. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. ```tsx diff --git a/docs/ReferenceField.md b/docs/ReferenceField.md index bd6c0ac6a68..555f3cd422c 100644 --- a/docs/ReferenceField.md +++ b/docs/ReferenceField.md @@ -145,7 +145,7 @@ You can also use a custom `link` function to get a custom path for the children. ## `offline` -`` can display a custom message when data cannot be fetched because of connectivity issues. +`` displays a message when data cannot be fetched because of connectivity issues. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. ```tsx diff --git a/docs/ReferenceInput.md b/docs/ReferenceInput.md index 3bc53e1ecb3..69f31f52f84 100644 --- a/docs/ReferenceInput.md +++ b/docs/ReferenceInput.md @@ -193,7 +193,7 @@ const filters = [ ## `offline` -`` can display a custom message when data cannot be fetched because of connectivity issues. +`` displays a message when data cannot be fetched because of connectivity issues. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. ```tsx diff --git a/docs/ReferenceManyCount.md b/docs/ReferenceManyCount.md index 7f799c8c2f1..051d1a6c245 100644 --- a/docs/ReferenceManyCount.md +++ b/docs/ReferenceManyCount.md @@ -124,7 +124,7 @@ When used in conjunction to the `filter` prop, the link will point to the list v ## `offline` -`` can display a custom message when data cannot be fetched because of connectivity issues. +`` displays a message when data cannot be fetched because of connectivity issues. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. ```tsx diff --git a/docs/ReferenceOneField.md b/docs/ReferenceOneField.md index 5afbc1d534c..60efe6e9a3f 100644 --- a/docs/ReferenceOneField.md +++ b/docs/ReferenceOneField.md @@ -154,7 +154,7 @@ You can also set the `link` prop to a string, which will be used as the link typ ## `offline` -`` can display a custom message when data cannot be fetched because of connectivity issues. +`` displays a message when data cannot be fetched because of connectivity issues. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. ```tsx From 47cdf92d13de30239f48a5e8e74fd5a4a7730286 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 15 May 2025 16:51:34 +0200 Subject: [PATCH 27/58] Improve EditView types --- packages/ra-ui-materialui/src/detail/EditView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 57b102b1e81..a9c1f77ddfd 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -88,7 +88,7 @@ export const EditView = (inProps: EditViewProps) => { export interface EditViewProps extends Omit, 'id' | 'title'> { - actions?: ReactNode | false; + actions?: ReactNode; aside?: ReactNode; offline?: ReactNode; component?: ElementType; From 466aeb18484c16edbc3a44e7bd3bb16e5ae51051 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 19 May 2025 14:51:22 +0200 Subject: [PATCH 28/58] Apply suggestions from code review Co-authored-by: Francois Zaninotto --- docs/DataProviders.md | 4 ++-- docs/Edit.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 1ef257fa281..e05009063ec 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -887,13 +887,13 @@ export default App; ## Offline Support -React Query supports offline/local-first applications. To enable it in your React Admin application, install the required React Query packages: +React-admin supports offline/local-first applications. To enable this feature, install the following react-query packages: ```sh yarn add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister ``` -Then, register default functions for React Admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React Admin provides the `addOfflineSupportToQueryClient` function for this: +Then, register default functions for react-admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React-admin provides the `addOfflineSupportToQueryClient` function for this: ```ts // in src/queryClient.ts diff --git a/docs/Edit.md b/docs/Edit.md index 47d6cbfb023..0b4bfed13db 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -493,7 +493,7 @@ The default `onError` function is: ## `offline` -It's possible that a `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. +It's possible that an `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. From 72ff0b1e76567ccab72707549230aa589199b85d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 19 May 2025 15:24:37 +0200 Subject: [PATCH 29/58] Fix exports --- packages/ra-ui-materialui/src/list/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/list/index.ts b/packages/ra-ui-materialui/src/list/index.ts index 24a0cd39895..1dbfc6a79fc 100644 --- a/packages/ra-ui-materialui/src/list/index.ts +++ b/packages/ra-ui-materialui/src/list/index.ts @@ -13,7 +13,6 @@ export * from './ListGuesser'; export * from './ListNoResults'; export * from './ListToolbar'; export * from './ListView'; -export * from '../Offline'; export * from './pagination'; export * from './Placeholder'; export * from './SimpleList'; From 6d3daeb18a8233ee0f94ac774ec2d815782f1185 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:00:55 +0200 Subject: [PATCH 30/58] Fix list children handling of offline state --- packages/ra-core/src/dataTable/DataTableBase.tsx | 2 +- packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx | 2 +- packages/ra-ui-materialui/src/list/SingleFieldList.tsx | 2 +- packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/dataTable/DataTableBase.tsx b/packages/ra-core/src/dataTable/DataTableBase.tsx index cda81dc0f47..a89e4c50401 100644 --- a/packages/ra-core/src/dataTable/DataTableBase.tsx +++ b/packages/ra-core/src/dataTable/DataTableBase.tsx @@ -169,7 +169,7 @@ export const DataTableBase = function DataTable< return null; } - if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (isPaused && (isPlaceholderData || data == null)) { if (offline) { return offline; } diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index 312de3a127f..6b1144ea070 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -117,7 +117,7 @@ export const SimpleList = ( return null; } - if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (isPaused && (isPlaceholderData || data == null)) { if (offline) { return offline; } diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index 90ca75ea8a9..a82d2acb198 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -90,7 +90,7 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { return null; } - if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (isPaused && (isPlaceholderData || data == null)) { if (offline) { return offline; } diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index 66920d7f233..beca48005d6 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -249,7 +249,7 @@ export const Datagrid: React.ForwardRefExoticComponent< return null; } - if (isPaused && (isPlaceholderData || data == null || !data?.length)) { + if (isPaused && (isPlaceholderData || data == null)) { if (offline) { return offline; } From c7b22d65972d7e03503bf7d0a1efc28e92883ba0 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:01:10 +0200 Subject: [PATCH 31/58] Fix detail views handling of offline state --- packages/ra-ui-materialui/src/detail/EditView.tsx | 2 +- packages/ra-ui-materialui/src/detail/ShowView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index a9c1f77ddfd..211a3d2a5d5 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -40,7 +40,7 @@ export const EditView = (inProps: EditViewProps) => { const { resource, defaultTitle, record, isPending, isPaused } = useEditContext(); - if (isPaused && offline) { + if (isPaused && record == null && offline) { return (
{ const { resource, defaultTitle, isPaused, record } = useShowContext(); const { hasEdit } = useResourceDefinition(); - if (isPaused && offline) { + if (isPaused && record == null && offline) { return (
Date: Tue, 20 May 2025 14:01:29 +0200 Subject: [PATCH 32/58] Fix reference inputs handling of offline state --- packages/ra-core/src/controller/input/ReferenceInputBase.tsx | 4 +++- packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/controller/input/ReferenceInputBase.tsx b/packages/ra-core/src/controller/input/ReferenceInputBase.tsx index f9c2a668a54..9c0ef85af64 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputBase.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputBase.tsx @@ -79,7 +79,9 @@ export const ReferenceInputBase = (props: ReferenceInputBaseProps) => { filter, }); - return controllerProps.isPaused ? ( + const { isPaused, allChoices } = controllerProps; + + return isPaused && allChoices == null ? ( offline ) : ( diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx index 419ce545478..5d69ad47451 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx @@ -97,8 +97,9 @@ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { sort, filter, }); + const { isPaused, allChoices } = controllerProps; - return controllerProps.isPaused ? ( + return isPaused && allChoices == null ? ( offline ?? ( From 01a1a94bd4e7b596b2e8471b671492c8852d09c6 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:02:20 +0200 Subject: [PATCH 33/58] Fix ReferenceOneField --- .../src/field/ReferenceOneField.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index 0942da763db..5079bb547f0 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -88,17 +88,18 @@ export const ReferenceOneField = < ) : null; if ( - !record || - (!controllerProps.isPending && - !controllerProps.isPaused && - controllerProps.referenceRecord == null) + !record && + !controllerProps.isPending && + !controllerProps.isPaused && + controllerProps.referenceRecord == null ) { return empty; } if ( - !record || - (controllerProps.isPaused && controllerProps.referenceRecord == null) + !record && + controllerProps.isPaused && + controllerProps.referenceRecord == null ) { return offline; } From 10ae938197fe6ffe5d2e7096a75e4a30307c6b1a Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:03:03 +0200 Subject: [PATCH 34/58] Improve ReferenceInput --- packages/ra-ui-materialui/src/input/ReferenceInput.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index 81664d4ce38..73e00a4bc5c 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -79,7 +79,11 @@ export const ReferenceInput = (props: ReferenceInputProps) => { {...rest} offline={ offline ?? ( - + ) From 9849a2cae93be804344afc32d3279141e01a6236 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:03:59 +0200 Subject: [PATCH 35/58] Correctly export Offline component --- packages/ra-ui-materialui/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 82696d33f40..0a645b57349 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -12,3 +12,4 @@ export * from './list'; export * from './preferences'; export * from './AdminUI'; export * from './AdminContext'; +export * from './Offline'; From 4a3cb09d18aaa324f96ba4c9d044be513db123a3 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 14:47:18 +0200 Subject: [PATCH 36/58] Avoid offline specific styles in details views --- packages/ra-ui-materialui/src/detail/EditView.tsx | 14 ++------------ packages/ra-ui-materialui/src/detail/ShowView.tsx | 14 ++------------ 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 211a3d2a5d5..d5d4bd63122 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -43,14 +43,8 @@ export const EditView = (inProps: EditViewProps) => { if (isPaused && record == null && offline) { return ( -
- {offline} +
+ {offline}
); @@ -117,10 +111,6 @@ const Root = styled('div', { [`& .${EditClasses.noActions}`]: { marginTop: '1em', }, - [`& .${EditClasses.offline}`]: { - flexDirection: 'column', - alignItems: 'unset', - }, [`& .${EditClasses.card}`]: { flex: '1 1 auto', }, diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index de4200ffc14..b9a7b2ac9c0 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -40,14 +40,8 @@ export const ShowView = (inProps: ShowViewProps) => { if (isPaused && record == null && offline) { return ( -
- {offline} +
+ {offline}
); @@ -111,10 +105,6 @@ const Root = styled('div', { [`& .${ShowClasses.noActions}`]: { marginTop: '1em', }, - [`& .${ShowClasses.offline}`]: { - flexDirection: 'column', - alignItems: 'unset', - }, [`& .${ShowClasses.card}`]: { flex: '1 1 auto', }, From 84207e0989c447739ddeebb7577a87a8be1e1358 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 20 May 2025 16:11:52 +0200 Subject: [PATCH 37/58] Rename isOnline hook to useIsOffline --- examples/simple/src/Layout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/simple/src/Layout.tsx b/examples/simple/src/Layout.tsx index 491cae62269..beb9249aa00 100644 --- a/examples/simple/src/Layout.tsx +++ b/examples/simple/src/Layout.tsx @@ -13,12 +13,12 @@ import OfflineIcon from '@mui/icons-material/SignalWifiConnectedNoInternet4'; import '../assets/app.css'; const MyAppBar = () => { - const isOnline = useIsOnline(); + const isOffline = useIsOffine(); return ( - {!isOnline ? ( + {isOffline ? ( ( ); -const useIsOnline = () => { +const useIsOffine = () => { const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); React.useEffect(() => { @@ -58,7 +58,7 @@ const useIsOnline = () => { return onlineManager.subscribe(handleChange); }, []); - return isOnline; + return !isOnline; }; /** From 2e2e10cd058d130c7ec2ac2555956df3b7eecbc9 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:51:30 +0200 Subject: [PATCH 38/58] Improve Offline component design for reference fields and inputs --- docs/DataProviders.md | 4 +++ examples/simple/src/comments/CommentList.tsx | 4 ++- examples/simple/src/posts/PostEdit.tsx | 19 ++++++++++++ examples/simple/src/posts/PostShow.tsx | 30 ++++++++++++++++++- examples/simple/src/tags/TagList.tsx | 23 ++++++++++---- packages/ra-ui-materialui/src/Offline.tsx | 29 +++++++++++++++--- .../src/field/ReferenceField.tsx | 2 +- .../src/field/ReferenceManyCount.tsx | 2 +- .../src/field/ReferenceOneField.tsx | 2 +- .../src/input/ReferenceArrayInput.tsx | 8 +++-- .../src/list/SingleFieldList.tsx | 2 +- 11 files changed, 108 insertions(+), 17 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index e05009063ec..4bf666a7425 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -945,6 +945,10 @@ export const App = () => ( ``` {% endraw %} +This is enough to make all the standard react-admin features support offline scenarios. + +## Adding Offline Support To Custom Mutations + If you have [custom mutations](./Actions.md#calling-custom-methods) on your dataProvider, you can enable offline support for them too. For instance, if your `dataProvider` exposes a `banUser()` method: ```ts diff --git a/examples/simple/src/comments/CommentList.tsx b/examples/simple/src/comments/CommentList.tsx index cdc6bb02d9d..23b4f365f20 100644 --- a/examples/simple/src/comments/CommentList.tsx +++ b/examples/simple/src/comments/CommentList.tsx @@ -30,6 +30,7 @@ import { useListContext, useTranslate, Exporter, + Offline, } from 'react-admin'; const commentFilters = [ @@ -63,9 +64,10 @@ const exporter: Exporter = (records, fetchRelatedRecords) => }); const CommentGrid = () => { - const { data } = useListContext(); + const { data, isPaused, isPlaceholderData } = useListContext(); const translate = useTranslate(); + if (isPaused && (data == null || isPlaceholderData)) return ; if (!data) return null; return ( diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index e45a2c2b736..a88acfc5e3a 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -31,6 +31,7 @@ import { useCreateSuggestionContext, EditActionsProps, CanAccess, + Translate, } from 'react-admin'; import { Box, @@ -40,7 +41,9 @@ import { DialogActions, DialogContent, TextField as MuiTextField, + Tooltip, } from '@mui/material'; +import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; import PostTitle from './PostTitle'; import TagReferenceInput from './TagReferenceInput'; @@ -229,6 +232,22 @@ const PostEdit = () => ( reference="comments" target="post_id" sx={{ lineHeight: 'inherit' }} + offline={ + + } + > + theme.spacing(0.5), + }} + /> + + } /> } > diff --git a/examples/simple/src/posts/PostShow.tsx b/examples/simple/src/posts/PostShow.tsx index e3e96c84656..0921a591239 100644 --- a/examples/simple/src/posts/PostShow.tsx +++ b/examples/simple/src/posts/PostShow.tsx @@ -23,7 +23,10 @@ import { useShowController, useLocaleState, useRecordContext, + Translate, } from 'react-admin'; +import { Tooltip } from '@mui/material'; +import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; import PostTitle from './PostTitle'; const CreateRelatedComment = () => { @@ -112,11 +115,36 @@ const PostShow = () => { span': { + display: 'flex', + alignItems: 'center', + }, + }} count={ + } + > + + theme.spacing(0.5), + }} + /> + + } /> } > diff --git a/examples/simple/src/tags/TagList.tsx b/examples/simple/src/tags/TagList.tsx index bf3dd443ee5..ff2329705f4 100644 --- a/examples/simple/src/tags/TagList.tsx +++ b/examples/simple/src/tags/TagList.tsx @@ -6,6 +6,7 @@ import { useListContext, EditButton, Title, + Offline, } from 'react-admin'; import { Box, @@ -25,15 +26,27 @@ const TagList = () => ( - - - - - + ); +const TagListView = () => { + const { data, isPaused } = useListContext(); + + if (isPaused && data == null) { + return ; + } + + return ( + + + + + + ); +}; + const Tree = () => { const { data, defaultTitle } = useListContext(); const [openChildren, setOpenChildren] = useState([]); diff --git a/packages/ra-ui-materialui/src/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx index 953fad29111..d80766bed7a 100644 --- a/packages/ra-ui-materialui/src/Offline.tsx +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -7,9 +7,10 @@ import { Typography, } from '@mui/material'; import { useGetResourceLabel, useResourceContext, useTranslate } from 'ra-core'; +import clsx from 'clsx'; export const Offline = (props: Offline) => { - const { message: messageProp } = props; + const { icon, message: messageProp, variant = 'standard', ...rest } = props; const translate = useTranslate(); const resource = useResourceContext(props); const getResourceLabel = useGetResourceLabel(); @@ -31,23 +32,43 @@ export const Offline = (props: Offline) => { } ); return ( - + {message} ); }; -export interface Offline extends AlertProps { +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 { diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.tsx index 1a12b899e29..23d3c4fe9aa 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.tsx @@ -105,7 +105,7 @@ export interface ReferenceFieldProps< // useful to prevent click bubbling in a datagrid with rowClick const stopPropagation = e => e.stopPropagation(); -const defaultOffline = ; +const defaultOffline = ; export const ReferenceFieldView = < RecordType extends Record = Record, diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx index a31da69b352..25bb45d516b 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.tsx @@ -21,7 +21,7 @@ import { sanitizeFieldRestProps } from './sanitizeFieldRestProps'; import { Link } from '../Link'; import { Offline } from '../Offline'; -const defaultOffline = ; +const defaultOffline = ; /** * Fetch and render the number of records related to the current one diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index 5079bb547f0..929082914df 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -19,7 +19,7 @@ import { FieldProps } from './types'; import { ReferenceFieldView } from './ReferenceField'; import { Offline } from '../Offline'; -const defaultOffline = ; +const defaultOffline = ; /** * Render the related record in a one-to-one relationship diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx index 5d69ad47451..4049e3deca5 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx @@ -101,8 +101,12 @@ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { return isPaused && allChoices == null ? ( offline ?? ( - - + + ) ) : ( diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index a82d2acb198..c85b3a865ef 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -209,4 +209,4 @@ declare module '@mui/material/styles' { } } -const DefaultOffline = ; +const DefaultOffline = ; From 8452c4f966615d37991647d332f65ec4bd23615c Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:28:11 +0200 Subject: [PATCH 39/58] Make sure users know about pending operations --- examples/simple/src/Layout.tsx | 16 +---- packages/ra-core/src/core/index.ts | 1 + packages/ra-core/src/core/useIsOffine.ts | 21 ++++++ packages/ra-language-english/src/index.ts | 2 + packages/ra-language-french/src/index.ts | 2 + .../src/layout/LoadingIndicator.tsx | 67 +++++++++++++++---- 6 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 packages/ra-core/src/core/useIsOffine.ts diff --git a/examples/simple/src/Layout.tsx b/examples/simple/src/Layout.tsx index beb9249aa00..498aa61d1aa 100644 --- a/examples/simple/src/Layout.tsx +++ b/examples/simple/src/Layout.tsx @@ -6,8 +6,9 @@ import { InspectorButton, TitlePortal, useNotify, + useIsOffine, } from 'react-admin'; -import { onlineManager, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { Stack, Tooltip } from '@mui/material'; import OfflineIcon from '@mui/icons-material/SignalWifiConnectedNoInternet4'; import '../assets/app.css'; @@ -48,19 +49,6 @@ export const MyLayout = ({ children }) => ( ); -const useIsOffine = () => { - const [isOnline, setIsOnline] = React.useState(onlineManager.isOnline()); - - React.useEffect(() => { - const handleChange = () => { - setIsOnline(onlineManager.isOnline()); - }; - return onlineManager.subscribe(handleChange); - }, []); - - return !isOnline; -}; - /** * When react-query resumes persisted mutations through their default functions (provided in the getOfflineFirstQueryClient file) after the browser tab * has been closed, it cannot handle their side effects unless we set up some defaults. In order to leverage the react-admin notification system diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts index fb7b21aa759..543c0ca85a8 100644 --- a/packages/ra-core/src/core/index.ts +++ b/packages/ra-core/src/core/index.ts @@ -15,6 +15,7 @@ export * from './SourceContext'; export * from './useFirstResourceWithListAccess'; export * from './useGetResourceLabel'; export * from './useGetRecordRepresentation'; +export * from './useIsOffine'; export * from './useResourceDefinitionContext'; export * from './useResourceContext'; export * from './useResourceDefinition'; diff --git a/packages/ra-core/src/core/useIsOffine.ts b/packages/ra-core/src/core/useIsOffine.ts new file mode 100644 index 00000000000..6e54c89ef1d --- /dev/null +++ b/packages/ra-core/src/core/useIsOffine.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; +}; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 3e0f0370f0e..389d4f150d2 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -174,6 +174,8 @@ const englishMessages: TranslationMessages = { 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.', + pending_operations: + 'There is a pending operation due to network not being available |||| There are %{smart_count} pending operations due to network not being available', }, validation: { required: 'Required', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index 90a3e8de19a..06e14d7f8b8 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -182,6 +182,8 @@ const frenchMessages: TranslationMessages = { "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.', + pending_operations: + 'Il y a une opération en attente due à un problème de réseau |||| Il y a %{smart_count} opérations en attente dues à un problème de réseau', }, validation: { required: 'Ce champ est requis', diff --git a/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx b/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx index 99007ab5bf7..385d4eb4859 100644 --- a/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx +++ b/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx @@ -9,9 +9,11 @@ import { } from '@mui/material/styles'; import clsx from 'clsx'; import CircularProgress from '@mui/material/CircularProgress'; -import { useLoading } from 'ra-core'; +import { Translate, useIsOffine, useLoading } from 'ra-core'; import { RefreshIconButton, type RefreshIconButtonProps } from '../button'; +import { Badge, Tooltip } from '@mui/material'; +import { useMutationState } from '@tanstack/react-query'; export const LoadingIndicator = (inProps: LoadingIndicatorProps) => { const props = useThemeProps({ @@ -20,6 +22,12 @@ export const LoadingIndicator = (inProps: LoadingIndicatorProps) => { }); const { className, onClick, sx, ...rest } = props; const loading = useLoading(); + const isOffline = useIsOffine(); + const pendingMutations = useMutationState({ + filters: { + status: 'pending', + }, + }); const theme = useTheme(); return ( @@ -30,18 +38,51 @@ export const LoadingIndicator = (inProps: LoadingIndicatorProps) => { }`} onClick={onClick} /> - {loading && ( - - )} + {loading ? ( + isOffline ? ( + + {pendingMutations.length > 1 + ? `There are ${pendingMutations.length} pending + operations due to network not being available` + : `There is a pending operation due to network not being available`} + + } + > + + + + + ) : ( + + ) + ) : null} ); }; From a55d5ef6daf6d1b00ff6e02def6d5151816e7aa4 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:03:06 +0200 Subject: [PATCH 40/58] Improve mutation mode selector --- examples/simple/src/posts/PostCreate.tsx | 114 +++++++---------------- 1 file changed, 36 insertions(+), 78 deletions(-) diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index f6dd0e6e1df..9db6b78e240 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -31,19 +31,14 @@ import { import { useFormContext, useWatch } from 'react-hook-form'; import { Button, - ButtonGroup, - ClickAwayListener, Dialog, DialogActions, DialogContent, - Grow, + Menu, MenuItem, - MenuList, - Paper, - Popper, Stack, } from '@mui/material'; -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import MoreButton from '@mui/icons-material/MoreVert'; // Client side id generation. We start from 100 to avoid querying the post list to get the next id as we // may be offline and accessing this page directly (without going through the list page first) which would @@ -334,88 +329,51 @@ const MutationModesSelector = (props: { setMutationMode: (mode: MutationMode) => void; }) => { const { setMutationMode, mutationMode } = props; - const [open, setOpen] = React.useState(false); - const anchorRef = React.useRef(null); - const buttonRef = React.useRef(null); - - const handleMenuItemClick = (mutationMode: MutationMode) => { - setOpen(false); - setMutationMode(mutationMode); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); }; - - const handleToggle = () => { - setOpen(prevOpen => !prevOpen); + const handleClose = () => { + setAnchorEl(null); }; - const handleClose = (event: Event) => { - if ( - anchorRef.current && - anchorRef.current.contains(event.target as HTMLElement) - ) { - return; - } - - setOpen(false); + const handleMenuItemClick = (mutationMode: MutationMode) => { + setMutationMode(mutationMode); }; return ( <> - } > - - - - + - {({ TransitionProps, placement }) => ( - ( + handleMenuItemClick(mutationMode)} > - - - - {MutationModes.map(mutationMode => ( - - handleMenuItemClick( - mutationMode - ) - } - > - {mutationMode} - - ))} - - - - - )} - + {mutationMode} + + ))} + ); }; From 8384ca3af0cc95e8d9df92fae8eeb6750aec3fff Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:09:27 +0200 Subject: [PATCH 41/58] Improve documentation --- docs/DataTable.md | 4 ++- docs/Datagrid.md | 18 +++++++++++-- docs/Edit.md | 6 +++-- docs/Show.md | 6 +++-- docs/SimpleList.md | 33 +++++++++++++++++++++++ docs/SingleFieldList.md | 58 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 112 insertions(+), 13 deletions(-) diff --git a/docs/DataTable.md b/docs/DataTable.md index 0a02e153bfb..5e94245eaa0 100644 --- a/docs/DataTable.md +++ b/docs/DataTable.md @@ -757,7 +757,9 @@ export const PostList = () => ( ## `offline` -It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: + +> No connectivity. Could not fetch data. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. diff --git a/docs/Datagrid.md b/docs/Datagrid.md index 9d26fd75edc..599c34b53be 100644 --- a/docs/Datagrid.md +++ b/docs/Datagrid.md @@ -643,9 +643,23 @@ export const PostList = () => ( ## `offline` -It's possible that a Datagrid will have no records to display because of connectivity issues. In that case, the Datagrid will display a message indicating data couldn't be fetched. This message is translatable and its key is `ra.notification.offline`. +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: -You can customize the content to display by passing a component to the `offline` prop: +> No connectivity. Could not fetch data. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: ```tsx const CustomOffline = () =>
No network. Data couldn't be fetched.
; diff --git a/docs/Edit.md b/docs/Edit.md index 0b4bfed13db..e4a31e56d58 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -72,7 +72,7 @@ You can customize the `` component using the following props: | `id` | | `string | number` | | the id of the record to edit | | `mutationMode` | | `pessimistic | optimistic | undoable` | | switch to optimistic or pessimistic mutations (undoable by default) | | `mutationOptions` | | `object` | | options for the `dataProvider.update()` call | -| `offline` | | `ReactNode` | | The content rendered to render when data could not be fetched because of connectivity issues | +| `offline` | | `ReactNode` | `` | The content rendered to render when data could not be fetched because of connectivity issues | | `queryOptions` | | `object` | | options for the `dataProvider.getOne()` call | | `redirect` | | `string | Function | false` | | change the redirect location after successful creation | | `resource` | | `string` | | override the name of the resource to create | @@ -493,7 +493,9 @@ The default `onError` function is: ## `offline` -It's possible that an `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: + +> No connectivity. Could not fetch data. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. diff --git a/docs/Show.md b/docs/Show.md index 3a806f3f3bd..fb79d8fd615 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -68,7 +68,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 content rendered to render when data could not be fetched because of connectivity issues +| `offline` | | `ReactNode` | `` | The content rendered to render when data could not be fetched because of connectivity issues | | `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 @@ -279,7 +279,9 @@ export const PostShow = () => ( ## `offline` -It's possible that a `` will have no record to display because of connectivity issues. In that case, `` will display a message indicating data couldn't be fetched. +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: + +> No connectivity. Could not fetch data. You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. diff --git a/docs/SimpleList.md b/docs/SimpleList.md index 98379633bde..07b908341ef 100644 --- a/docs/SimpleList.md +++ b/docs/SimpleList.md @@ -47,6 +47,7 @@ export const PostList = () => ( | `rowClick` | Optional |mixed | `"edit"` | The action to trigger when the user clicks on a row. | | `leftAvatar` | Optional | function | | A function returning an `` component to display before the primary text. | | `leftIcon` | Optional | function | | A function returning an `` component to display before the primary text. | +| `offline` | Optional | Element | `` | The content rendered to render when data could not be fetched because of connectivity issues. | | `rightAvatar` | Optional | function | | A function returning an `` component to display after the primary text. | | `rightIcon` | Optional | function | | A function returning an `` component to display after the primary text. | | `rowStyle` | Optional | function | | A function returning a style object to apply to each row. | @@ -80,6 +81,38 @@ This prop should be a function returning an `` component. When present, This prop should be a function returning an `` component. When present, the `` renders a `` before the `` +## `offline` + +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: + +> No connectivity. Could not fetch data. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```tsx +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +const BookList = () => ( + + } /> + +); +``` + ## `primaryText` The `primaryText`, `secondaryText` and `tertiaryText` props can accept 4 types of values: diff --git a/docs/SingleFieldList.md b/docs/SingleFieldList.md index 4fc55b34917..55f5aa356e3 100644 --- a/docs/SingleFieldList.md +++ b/docs/SingleFieldList.md @@ -83,12 +83,13 @@ You can customize how each record is displayed by passing a Field component as c `` accepts the following props: -| Prop | Required | Type | Default | Description | -| ----------- | -------- | ------------------------- | ------- | ----------------------------------------------- | -| `children` | Optional | `ReactNode` | | React element to render for each record | -| `empty` | Optional | `ReactNode` | | React element to display when the list is empty | -| `linkType` | Optional | `'edit' | 'show' | false` | `edit` | The target of the link on each item | -| `sx` | Optional | `object` | | The sx props of the Material UI Box component | +| Prop | Required | Type | Default | Description | +| ----------- | -------- | ------------------------- | ------------------------------ | ----------------------------------------------- | +| `children` | Optional | `ReactNode` | | React element to render for each record | +| `empty` | Optional | `ReactNode` | | React element to display when the list is empty | +| `linkType` | Optional | `'edit' | 'show' | false` | `edit` | The target of the link on each item | +| `offline` | Optional | Element | `` | The content rendered to render when data could not be fetched because of connectivity issues. | +| `sx` | Optional | `object` | | The sx props of the Material UI Box component | Additional props are passed down to the underlying [Material UI `` component](https://mui.com/material-ui/react-stack/). @@ -133,6 +134,51 @@ The `` items link to the edition page by default. You can set t * `linkType="show"`: links to the show page. * `linkType={false}`: does not create any link. +## `offline` + +It's possible that a `` will have no records to display because of connectivity issues. In that case, `` will display the following message: + +> No connectivity. Could not fetch data. + +You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key. + +```tsx +const messages = { + ra: { + notification: { + offline: "No network. Data couldn't be fetched.", + } + } +} +``` + +If you need to go beyond text, pass a custom element as the `` prop: + +```tsx +import { + Show, + SimpleShowLayout, + TextField, + ReferenceArrayField, + SingleFieldList +} from 'react-admin'; + +const Offline = () => ( +

No network. Data couldn't be fetched.

+); + +const PostShow = () => ( + + + + + } /> + + + +); +``` + ## `sx`: CSS API The `` component accepts the usual `className` prop. You can also override the styles of the inner components thanks to the `sx` property. This property accepts the following subclasses: From 02dbe24ef7b716be02e72a570aacfa561fdd4cce Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:06:06 +0200 Subject: [PATCH 42/58] Improve notifications for offline mutations --- examples/simple/src/i18n/en.ts | 6 ++++ examples/simple/src/i18n/fr.ts | 6 ++++ .../button/useDeleteWithConfirmController.tsx | 19 ++++++++---- .../button/useDeleteWithUndoController.tsx | 20 +++++++++---- .../controller/create/useCreateController.ts | 30 +++++++++++++------ .../src/controller/edit/useEditController.ts | 30 +++++++++++++------ .../button/BulkDeleteWithConfirmButton.tsx | 18 +++++++---- .../src/button/BulkDeleteWithUndoButton.tsx | 18 +++++++---- .../button/BulkUpdateWithConfirmButton.tsx | 30 +++++++++++++------ .../src/button/BulkUpdateWithUndoButton.tsx | 17 ++++++++--- .../src/button/UpdateWithConfirmButton.tsx | 30 ++++++++++++++----- .../src/button/UpdateWithUndoButton.tsx | 30 ++++++++++++++----- .../src/input/InPlaceEditor/InPlaceEditor.tsx | 30 +++++++++++++------ 13 files changed, 206 insertions(+), 78 deletions(-) diff --git a/examples/simple/src/i18n/en.ts b/examples/simple/src/i18n/en.ts index d25cecf2569..16181cb0a7d 100644 --- a/examples/simple/src/i18n/en.ts +++ b/examples/simple/src/i18n/en.ts @@ -23,8 +23,14 @@ export const messages = { }, notifications: { created: 'Post created |||| %{smart_count} posts created', + pending_create: + 'Post will be created when your device will be online |||| %{smart_count} posts will be created when your device will be online', updated: 'Post updated |||| %{smart_count} posts updated', + pending_update: + 'Post will be updated when your device will be online |||| %{smart_count} posts will be updated when your device will be online', deleted: 'Post deleted |||| %{smart_count} posts deleted', + pending_delete: + 'Post will be deleted when your device will be online |||| %{smart_count} posts will be deleted when your device will be online', }, }, comments: { diff --git a/examples/simple/src/i18n/fr.ts b/examples/simple/src/i18n/fr.ts index 91be5e8eac6..17d2155e9ed 100644 --- a/examples/simple/src/i18n/fr.ts +++ b/examples/simple/src/i18n/fr.ts @@ -39,10 +39,16 @@ export default { }, notifications: { created: 'Article créé |||| %{smart_count} articles créés', + pending_create: + "L'Article créé quand votre appareil sera connecté |||| %{smart_count} articles seront créés quand votre appareil sera connecté", updated: 'Article mis à jour |||| %{smart_count} articles mis à jour', + pending_update: + "L'article sera mis à jour quand votre appareil sera connecté |||| %{smart_count} articles seront mis à jour quand votre appareil sera connecté", deleted: 'Article supprimé |||| %{smart_count} articles supprimés', + pending_delete: + "L'article sera supprimé quand votre appareil sera connecté |||| %{smart_count} articles seront supprimés quand votre appareil sera connecté", }, }, comments: { diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx index 15b72f8f8d4..a8acd403a6f 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx @@ -11,7 +11,7 @@ import { useUnselect } from '../../controller'; import { useRedirect, RedirectionSideEffect } from '../../routing'; import { useNotify } from '../../notification'; import { RaRecord, MutationMode, DeleteParams } from '../../types'; -import { useResourceContext } from '../../core'; +import { useIsOffine, useResourceContext } from '../../core'; import { useTranslate } from '../../i18n'; /** @@ -90,6 +90,7 @@ const useDeleteWithConfirmController = < const redirect = useRedirect(); const translate = useTranslate(); + const isOffline = useIsOffine(); const [deleteOne, { isPending }] = useDelete( resource, undefined, @@ -97,15 +98,21 @@ const useDeleteWithConfirmController = < onSuccess: () => { setOpen(false); notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage ?? isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { smart_count: 1, - _: translate('ra.notification.deleted', { - smart_count: 1, - }), + _: translate( + isOffline + ? 'ra.notification.pending_delete' + : 'ra.notification.deleted', + { + smart_count: 1, + } + ), }, undoable: mutationMode === 'undoable', } diff --git a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx index f04445c4b86..7d7367a1614 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx @@ -6,7 +6,7 @@ import { useUnselect } from '../../controller'; import { useRedirect, RedirectionSideEffect } from '../../routing'; import { useNotify } from '../../notification'; import { RaRecord, DeleteParams } from '../../types'; -import { useResourceContext } from '../../core'; +import { useIsOffine, useResourceContext } from '../../core'; import { useTranslate } from '../../i18n'; /** @@ -63,21 +63,29 @@ const useDeleteWithUndoController = < const unselect = useUnselect(resource); const redirect = useRedirect(); const translate = useTranslate(); + + const isOffline = useIsOffine(); const [deleteOne, { isPending }] = useDelete( resource, undefined, { onSuccess: () => { notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage ?? isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { smart_count: 1, - _: translate('ra.notification.deleted', { - smart_count: 1, - }), + _: translate( + isOffline + ? 'ra.notification.pending_delete' + : 'ra.notification.deleted', + { + smart_count: 1, + } + ), }, undoable: true, } diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index b078f5976f1..7e5d9247117 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -20,6 +20,7 @@ import { useResourceContext, useResourceDefinition, useGetResourceLabel, + useIsOffine, } from '../../core'; /** @@ -87,6 +88,7 @@ export const useCreateController = < unregisterMutationMiddleware, } = useMutationMiddlewares(); + const isOffline = useIsOffine(); const [create, { isPending: saving }] = useCreate< RecordType, MutationOptionsError, @@ -96,16 +98,26 @@ export const useCreateController = < if (onSuccess) { return onSuccess(data, variables, context); } - notify(`resources.${resource}.notifications.created`, { - type: 'info', - messageArgs: { - smart_count: 1, - _: translate(`ra.notification.created`, { + notify( + isOffline + ? `resources.${resource}.notifications.pending_create` + : `resources.${resource}.notifications.created`, + { + type: 'info', + messageArgs: { smart_count: 1, - }), - }, - undoable: mutationMode === 'undoable', - }); + _: translate( + isOffline + ? 'ra.notification.pending_create' + : 'ra.notification.created', + { + smart_count: 1, + } + ), + }, + undoable: mutationMode === 'undoable', + } + ); redirect(finalRedirectTo, resource, data.id, data); }, onError: (error: MutationOptionsError, variables, context) => { diff --git a/packages/ra-core/src/controller/edit/useEditController.ts b/packages/ra-core/src/controller/edit/useEditController.ts index c53e7436eb3..a271a2c80eb 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -19,6 +19,7 @@ import { useResourceContext, useGetResourceLabel, useGetRecordRepresentation, + useIsOffine, } from '../../core'; import { SaveContextValue, @@ -153,6 +154,7 @@ export const useEditController = < const recordCached = { id, previousData: record }; + const isOffline = useIsOffine(); const [update, { isPending: saving }] = useUpdate( resource, recordCached, @@ -161,16 +163,26 @@ export const useEditController = < if (onSuccess) { return onSuccess(data, variables, context); } - notify(`resources.${resource}.notifications.updated`, { - type: 'info', - messageArgs: { - smart_count: 1, - _: translate('ra.notification.updated', { + notify( + isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, + { + type: 'info', + messageArgs: { smart_count: 1, - }), - }, - undoable: mutationMode === 'undoable', - }); + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: 1, + } + ), + }, + undoable: mutationMode === 'undoable', + } + ); redirect(redirectTo, resource, data.id, data); }, onError: (error, variables, context) => { diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index 8e6a5d42551..73b4166a8d7 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx @@ -10,6 +10,7 @@ import { import { type MutationMode, useDeleteMany, + useIsOffine, useListContext, useNotify, useRefresh, @@ -50,6 +51,7 @@ export const BulkDeleteWithConfirmButton = ( const resource = useResourceContext(props); const refresh = useRefresh(); const translate = useTranslate(); + const isOffline = useIsOffine(); const [deleteMany, { isPending }] = useDeleteMany( resource, { ids: selectedIds, meta: mutationMeta }, @@ -57,15 +59,21 @@ export const BulkDeleteWithConfirmButton = ( onSuccess: () => { refresh(); notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage ?? isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { smart_count: selectedIds.length, - _: translate('ra.notification.deleted', { - smart_count: selectedIds.length, - }), + _: translate( + isOffline + ? 'ra.notification.pending_delete' + : 'ra.notification.deleted', + { + smart_count: selectedIds.length, + } + ), }, undoable: mutationMode === 'undoable', } diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index bf215ba0dd7..215c2fcf052 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -7,6 +7,7 @@ import { } from '@mui/material/styles'; import { useDeleteMany, + useIsOffine, useRefresh, useNotify, useResourceContext, @@ -43,6 +44,7 @@ export const BulkDeleteWithUndoButton = ( const translate = useTranslate(); const [deleteMany, { isPending }] = useDeleteMany(); + const isOffline = useIsOffine(); const handleClick = e => { deleteMany( resource, @@ -50,15 +52,21 @@ export const BulkDeleteWithUndoButton = ( { onSuccess: () => { notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage ?? isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { smart_count: selectedIds.length, - _: translate('ra.notification.deleted', { - smart_count: selectedIds.length, - }), + _: translate( + isOffline + ? 'ra.notification.pending_delete' + : 'ra.notification.deleted', + { + smart_count: selectedIds.length, + } + ), }, undoable: true, } diff --git a/packages/ra-ui-materialui/src/button/BulkUpdateWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkUpdateWithConfirmButton.tsx index a32df5fe412..207a51013bd 100644 --- a/packages/ra-ui-materialui/src/button/BulkUpdateWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkUpdateWithConfirmButton.tsx @@ -16,6 +16,7 @@ import { type MutationMode, type RaRecord, type UpdateManyParams, + useIsOffine, } from 'ra-core'; import { Confirm } from '../layout'; @@ -36,6 +37,7 @@ export const BulkUpdateWithConfirmButton = ( const unselectAll = useUnselectAll(resource); const [isOpen, setOpen] = useState(false); const { selectedIds } = useListContext(); + const isOffline = useIsOffine(); const { confirmTitle = 'ra.message.bulk_update_title', @@ -46,16 +48,26 @@ export const BulkUpdateWithConfirmButton = ( mutationMode = 'pessimistic', onClick, onSuccess = () => { - notify(`resources.${resource}.notifications.updated`, { - type: 'info', - messageArgs: { - smart_count: selectedIds.length, - _: translate('ra.notification.updated', { + notify( + isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, + { + type: 'info', + messageArgs: { smart_count: selectedIds.length, - }), - }, - undoable: mutationMode === 'undoable', - }); + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: selectedIds.length, + } + ), + }, + undoable: mutationMode === 'undoable', + } + ); unselectAll(); setOpen(false); }, diff --git a/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx index cd5fe33dcd9..896c0c75c00 100644 --- a/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx @@ -15,6 +15,7 @@ import { type RaRecord, type UpdateManyParams, useTranslate, + useIsOffine, } from 'ra-core'; import type { UseMutationOptions } from '@tanstack/react-query'; @@ -34,6 +35,7 @@ export const BulkUpdateWithUndoButton = ( const unselectAll = useUnselectAll(resource); const refresh = useRefresh(); const translate = useTranslate(); + const isOffline = useIsOffine(); const { data, @@ -43,14 +45,21 @@ export const BulkUpdateWithUndoButton = ( onClick, onSuccess = () => { notify( - successMessage ?? `resources.${resource}.notifications.updated`, + successMessage ?? isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, { type: 'info', messageArgs: { smart_count: selectedIds.length, - _: translate('ra.notification.updated', { - smart_count: selectedIds.length, - }), + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: selectedIds.length, + } + ), }, undoable: true, } diff --git a/packages/ra-ui-materialui/src/button/UpdateWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/UpdateWithConfirmButton.tsx index 0055d86b252..607af222c76 100644 --- a/packages/ra-ui-materialui/src/button/UpdateWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/UpdateWithConfirmButton.tsx @@ -17,6 +17,7 @@ import { useRecordContext, useUpdate, useGetRecordRepresentation, + useIsOffine, } from 'ra-core'; import { Confirm } from '../layout'; @@ -36,6 +37,7 @@ export const UpdateWithConfirmButton = ( const resource = useResourceContext(props); const [isOpen, setOpen] = useState(false); const record = useRecordContext(props); + const isOffline = useIsOffine(); const { confirmTitle: confirmTitleProp, @@ -51,14 +53,26 @@ export const UpdateWithConfirmButton = ( const { meta: mutationMeta, onSuccess = () => { - notify(`resources.${resource}.notifications.updated`, { - type: 'info', - messageArgs: { - smart_count: 1, - _: translate('ra.notification.updated', { smart_count: 1 }), - }, - undoable: mutationMode === 'undoable', - }); + notify( + isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, + { + type: 'info', + messageArgs: { + smart_count: 1, + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: 1, + } + ), + }, + undoable: mutationMode === 'undoable', + } + ); }, onError = (error: Error | string) => { notify( diff --git a/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx index 331f9b731fe..eb469141689 100644 --- a/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx @@ -14,6 +14,7 @@ import { useUpdate, type UpdateParams, useTranslate, + useIsOffine, } from 'ra-core'; import type { UseMutationOptions } from '@tanstack/react-query'; @@ -29,6 +30,7 @@ export const UpdateWithUndoButton = (inProps: UpdateWithUndoButtonProps) => { const resource = useResourceContext(props); const refresh = useRefresh(); const translate = useTranslate(); + const isOffline = useIsOffine(); const { data, @@ -44,14 +46,26 @@ export const UpdateWithUndoButton = (inProps: UpdateWithUndoButtonProps) => { const { meta: mutationMeta, onSuccess = () => { - notify(`resources.${resource}.notifications.updated`, { - type: 'info', - messageArgs: { - smart_count: 1, - _: translate('ra.notification.updated', { smart_count: 1 }), - }, - undoable: true, - }); + notify( + isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, + { + type: 'info', + messageArgs: { + smart_count: 1, + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: 1, + } + ), + }, + undoable: true, + } + ); }, onError = (error: Error | string) => { notify( diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx index 53ce1d9130f..1704f6c190d 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx @@ -10,6 +10,7 @@ import { RecordContextProvider, type UseUpdateOptions, type RaRecord, + useIsOffine, } from 'ra-core'; import isEqual from 'lodash/isEqual'; import { styled } from '@mui/material/styles'; @@ -130,22 +131,33 @@ export const InPlaceEditor = < const notify = useNotify(); const translate = useTranslate(); const [update] = useUpdate(); + const isOffline = useIsOffine(); const { meta: mutationMeta, onSuccess = () => { dispatch({ type: 'success' }); if (mutationMode !== 'undoable' && !notifyOnSuccess) return; - notify(`resources.${resource}.notifications.updated`, { - type: 'info', - messageArgs: { - smart_count: 1, - _: translate('ra.notification.updated', { + notify( + isOffline + ? `resources.${resource}.notifications.pending_update` + : `resources.${resource}.notifications.updated`, + { + type: 'info', + messageArgs: { smart_count: 1, - }), - }, - undoable: mutationMode === 'undoable', - }); + _: translate( + isOffline + ? 'ra.notification.pending_update' + : 'ra.notification.updated', + { + smart_count: 1, + } + ), + }, + undoable: mutationMode === 'undoable', + } + ); }, onError = error => { notify('ra.notification.http_error', { From b648d085eb5ca16bb3adf43823ed4c487d210f55 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:12:35 +0200 Subject: [PATCH 43/58] Fix delete mutations success message handling --- .../controller/button/useDeleteWithConfirmController.tsx | 8 +++++--- .../src/controller/button/useDeleteWithUndoController.tsx | 8 +++++--- .../src/button/BulkDeleteWithConfirmButton.tsx | 8 +++++--- .../src/button/BulkDeleteWithUndoButton.tsx | 8 +++++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx index a8acd403a6f..bb49bfbcdff 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx @@ -98,9 +98,11 @@ const useDeleteWithConfirmController = < onSuccess: () => { setOpen(false); notify( - successMessage ?? isOffline - ? `resources.${resource}.notifications.pending_delete` - : `resources.${resource}.notifications.deleted`, + successMessage != null + ? successMessage + : isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { diff --git a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx index 7d7367a1614..2cee6be3cf0 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx @@ -71,9 +71,11 @@ const useDeleteWithUndoController = < { onSuccess: () => { notify( - successMessage ?? isOffline - ? `resources.${resource}.notifications.pending_delete` - : `resources.${resource}.notifications.deleted`, + successMessage != null + ? successMessage + : isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index 477aaf4972c..bdc1c4d89c4 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx @@ -59,9 +59,11 @@ export const BulkDeleteWithConfirmButton = ( onSuccess: () => { refresh(); notify( - successMessage ?? isOffline - ? `resources.${resource}.notifications.pending_delete` - : `resources.${resource}.notifications.deleted`, + successMessage != null + ? successMessage + : isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index ff1fe48ef7a..7f2e258589a 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -52,9 +52,11 @@ export const BulkDeleteWithUndoButton = ( { onSuccess: () => { notify( - successMessage ?? isOffline - ? `resources.${resource}.notifications.pending_delete` - : `resources.${resource}.notifications.deleted`, + successMessage != null + ? successMessage + : isOffline + ? `resources.${resource}.notifications.pending_delete` + : `resources.${resource}.notifications.deleted`, { type: 'info', messageArgs: { From 929d606fd85aea01308ccb66b1a883057907cea9 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:12:59 +0200 Subject: [PATCH 44/58] Fix ReferenceOneField empty case handling --- .../src/field/ReferenceOneField.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index fb3ec2a1848..f895eb4d322 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx @@ -95,19 +95,15 @@ export const ReferenceOneField = < ) : null; if ( - !record && - !controllerProps.isPending && - !controllerProps.isPaused && - controllerProps.referenceRecord == null + !record || + (!controllerProps.isPending && + !controllerProps.isPaused && + controllerProps.referenceRecord == null) ) { return empty; } - if ( - !record && - controllerProps.isPaused && - controllerProps.referenceRecord == null - ) { + if (controllerProps.isPaused && controllerProps.referenceRecord == null) { return offline; } From 1154f17a16efbc51edd6b7dd2f96bdeeb80bbaaf Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:35:45 +0200 Subject: [PATCH 45/58] Fix simple example dependencies --- examples/simple/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/simple/package.json b/examples/simple/package.json index e84203323a3..3dd8e9dd21a 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -12,10 +12,10 @@ "dependencies": { "@mui/icons-material": "^5.16.12", "@mui/material": "^5.16.12", - "@tanstack/query-sync-storage-persister": "5.47.0", + "@tanstack/query-sync-storage-persister": "^5.47.0", "@tanstack/react-query": "^5.21.7", "@tanstack/react-query-devtools": "^5.21.7", - "@tanstack/react-query-persist-client": "5.47.0", + "@tanstack/react-query-persist-client": "^5.47.0", "jsonexport": "^3.2.0", "lodash": "~4.17.5", "ra-data-fakerest": "^5.9.0", From 5b7f83a2d82d90714dc927278ad0d936fecc47ef Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:21:43 +0200 Subject: [PATCH 46/58] Fix JSDoc examples --- .../addOfflineSupportToQueryClient.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts index c5b41c0bf78..3916a6a4037 100644 --- a/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts +++ b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts @@ -9,10 +9,10 @@ import type { DataProvider } from '../types'; * * @example Adding offline support for the default mutations * // in src/App.tsx - * import { Admin, Resource, addOfflineSupportToQueryClient, reactAdminMutations } from 'react-admin'; + * import { addOfflineSupportToQueryClient } from 'react-admin'; + * import { QueryClient } from '@tanstack/react-query'; * import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; * import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; - * import { queryClient } from './queryClient'; * import { dataProvider } from './dataProvider'; * import { posts } from './posts'; * import { comments } from './comments'; @@ -21,11 +21,10 @@ import type { DataProvider } from '../types'; * storage: window.localStorage, * }); * - * addOfflineSupportToQueryClient({ - * queryClient, + * const queryClient = addOfflineSupportToQueryClient({ + * queryClient: new QueryClient(), * dataProvider, * resources: ['posts', 'comments'], - * mutations: [...reactAdminMutations, 'myCustomMutation'], * }); * * const App = () => ( @@ -46,10 +45,10 @@ import type { DataProvider } from '../types'; * * @example Adding offline support with custom mutations * // in src/App.tsx - * import { addOfflineSupportToQueryClient } from 'react-admin'; + * import { Admin, Resource, addOfflineSupportToQueryClient, reactAdminMutations } from 'react-admin'; + * import { QueryClient } from '@tanstack/react-query'; * import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; * import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; - * import { queryClient } from './queryClient'; * import { dataProvider } from './dataProvider'; * import { posts } from './posts'; * import { comments } from './comments'; @@ -58,10 +57,11 @@ import type { DataProvider } from '../types'; * storage: window.localStorage, * }); * - * addOfflineSupportToQueryClient({ - * queryClient, + * const queryClient = addOfflineSupportToQueryClient({ + * queryClient: new QueryClient(), * dataProvider, * resources: ['posts', 'comments'], + * mutations: [...reactAdminMutations, 'myCustomMutation'], * }); * * const App = () => ( From d114f96fd0c67489eb36c9692b8b093af6c14e55 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:22:58 +0200 Subject: [PATCH 47/58] Remove unnecessary CSS classes --- packages/ra-ui-materialui/src/detail/EditView.tsx | 1 - packages/ra-ui-materialui/src/detail/ShowView.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 81279983cd4..6573a4adec5 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -93,7 +93,6 @@ export const EditClasses = { main: `${PREFIX}-main`, noActions: `${PREFIX}-noActions`, card: `${PREFIX}-card`, - offline: `${PREFIX}-offline`, }; const Root = styled('div', { diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 21920f58734..aae98b82c0a 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -88,7 +88,6 @@ export const ShowClasses = { main: `${PREFIX}-main`, noActions: `${PREFIX}-noActions`, card: `${PREFIX}-card`, - offline: `${PREFIX}-offline`, }; const Root = styled('div', { From 30a5ec05f2fd5230704f55a2d4b338b4e8f9dd70 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:24:11 +0200 Subject: [PATCH 48/58] Fix Offline types --- packages/ra-ui-materialui/src/Offline.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ra-ui-materialui/src/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx index d80766bed7a..fc6a711f189 100644 --- a/packages/ra-ui-materialui/src/Offline.tsx +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -72,19 +72,19 @@ const Root = styled(Alert, { declare module '@mui/material/styles' { interface ComponentNameToClassKey { - RaOffline: 'root'; + [PREFIX]: 'root'; } interface ComponentsPropsList { - RaOffline: Partial; + [PREFIX]: Partial; } interface Components { - RaOffline?: { - defaultProps?: ComponentsPropsList['RaOffline']; + [PREFIX]?: { + defaultProps?: ComponentsPropsList[typeof PREFIX]; styleOverrides?: ComponentsOverrides< Omit - >['RaOffline']; + >[typeof PREFIX]; }; } } From 68d00a72d09311fafd202d85199652da82f20228 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:25:01 +0200 Subject: [PATCH 49/58] Fix LoadingIndicator --- packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx b/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx index 385d4eb4859..4bde713a0e3 100644 --- a/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx +++ b/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx @@ -44,7 +44,9 @@ export const LoadingIndicator = (inProps: LoadingIndicatorProps) => { title={ {pendingMutations.length > 1 ? `There are ${pendingMutations.length} pending From ed0f0dfecfcdd06a2ae8b720fb7543c0e6cc93df Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:55:03 +0200 Subject: [PATCH 50/58] Fix SingleFieldList story --- packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx | 2 +- packages/ra-ui-materialui/src/list/SingleFieldList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx index 0d308657a71..5b20cce8548 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.stories.tsx @@ -128,7 +128,7 @@ export const Loading = () => ( ); export const Offline = () => ( - + ); diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index c85b3a865ef..96b33f9c194 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -90,7 +90,7 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { return null; } - if (isPaused && (isPlaceholderData || data == null)) { + if (isPaused && (isPlaceholderData || total == null)) { if (offline) { return offline; } From d0586a1e9fbc0f7a9ccbbfdf37198437979361ae Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 16:08:06 +0200 Subject: [PATCH 51/58] Fix ReferenceInput offline detection --- .../input/useReferenceInputController.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 1bfbd19f2f8..e183c62c3bb 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -79,7 +79,7 @@ export const useReferenceInputController = ( // fetch possible values const { - data: possibleValuesData = [], + data: possibleValuesData, total, pageInfo, isFetching: isFetchingPossibleValues, @@ -143,17 +143,30 @@ export const useReferenceInputController = ( }, [currentReferenceRecord]); // add current value to possible sources - let finalData: RecordType[], finalTotal: number | undefined; - if ( - !referenceRecord || - possibleValuesData.find(record => record.id === referenceRecord.id) - ) { - finalData = possibleValuesData; - finalTotal = total; - } else { - finalData = [referenceRecord, ...possibleValuesData]; - finalTotal = total == null ? undefined : total + 1; - } + const { finalData, finalTotal } = useMemo(() => { + if (isPaused && possibleValuesData == null) { + return { + finalData: null, + finalTotal: null, + }; + } + if ( + !referenceRecord || + (possibleValuesData ?? []).find( + record => record.id === referenceRecord.id + ) + ) { + return { + finalData: possibleValuesData, + finalTotal: total, + }; + } else { + return { + finalData: [referenceRecord, ...(possibleValuesData ?? [])], + finalTotal: total == null ? undefined : total + 1, + }; + } + }, [isPaused, referenceRecord, possibleValuesData, total]); const refetch = useCallback(() => { refetchGetList(); From b03dfb4b3fdc822ef85a13d56c31c6318fc1b13d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:04:01 +0200 Subject: [PATCH 52/58] Fix reference fields and inputs --- .../input/useReferenceArrayInputController.ts | 3 + .../src/form/choices/ChoicesContext.ts | 1 + .../src/form/choices/useChoicesContext.ts | 4 + packages/ra-ui-materialui/src/Labeled.tsx | 24 +++--- .../src/field/ReferenceArrayField.stories.tsx | 8 +- .../src/field/ReferenceManyField.stories.tsx | 36 ++++++++- .../src/input/AutocompleteInput.tsx | 34 +++++++-- .../src/input/ReferenceArrayInput.stories.tsx | 33 ++++----- .../src/input/ReferenceArrayInput.tsx | 16 ++-- .../src/input/ReferenceInput.stories.tsx | 74 ++++++++++++++----- .../src/input/ReferenceInput.tsx | 16 ++-- 11 files changed, 174 insertions(+), 75 deletions(-) diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index 1e18086911d..fa883c58b21 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -63,6 +63,7 @@ export const useReferenceArrayInputController = < isFetching: isFetchingGetMany, isPaused: isPausedGetMany, isPending: isPendingGetMany, + isPlaceholderData: isPlaceholderDataGetMany, refetch: refetchGetMany, } = useGetManyAggregate( reference, @@ -102,6 +103,7 @@ export const useReferenceArrayInputController = < isFetching: isFetchingGetList, isPaused: isPausedGetList, isPending: isPendingGetList, + isPlaceholderData: isPlaceholderDataGetList, refetch: refetchGetMatching, } = useGetList( reference, @@ -157,6 +159,7 @@ export const useReferenceArrayInputController = < isLoading: isLoadingGetMany || isLoadingGetList, isPaused: isPausedGetMany || isPausedGetList, isPending: isPendingGetMany || isPendingGetList, + isPlaceholderData: isPlaceholderDataGetMany || isPlaceholderDataGetList, page: params.page, perPage: params.perPage, refetch, diff --git a/packages/ra-core/src/form/choices/ChoicesContext.ts b/packages/ra-core/src/form/choices/ChoicesContext.ts index 1821769559a..a420a6351b0 100644 --- a/packages/ra-core/src/form/choices/ChoicesContext.ts +++ b/packages/ra-core/src/form/choices/ChoicesContext.ts @@ -21,6 +21,7 @@ export type ChoicesContextBaseValue = { isFetching: boolean; isLoading: boolean; isPaused: boolean; + isPlaceholderData: boolean; page: number; perPage: number; refetch: (() => void) | UseGetListHookValue['refetch']; diff --git a/packages/ra-core/src/form/choices/useChoicesContext.ts b/packages/ra-core/src/form/choices/useChoicesContext.ts index e7a31c13684..7528ee5ef1b 100644 --- a/packages/ra-core/src/form/choices/useChoicesContext.ts +++ b/packages/ra-core/src/form/choices/useChoicesContext.ts @@ -21,6 +21,8 @@ export const useChoicesContext = ( isLoading: options.isLoading ?? false, isPending: options.isPending ?? false, isFetching: options.isFetching ?? false, + isPaused: options.isPaused ?? false, + isPlaceholderData: options.isPlaceholderData ?? false, error: options.error, // When not in a ChoicesContext, paginating does not make sense (e.g. AutocompleteInput). perPage: Infinity, @@ -44,6 +46,8 @@ export const useChoicesContext = ( isLoading: list.isLoading ?? false, // we must take the one for useList, otherwise the loading state isn't synchronized with the data isPending: list.isPending ?? false, // same isFetching: list.isFetching ?? false, // same + isPaused: list.isPaused ?? false, // same + isPlaceholderData: list.isPlaceholderData ?? false, // same page: options.page ?? list.page, perPage: options.perPage ?? list.perPage, refetch: options.refetch ?? list.refetch, diff --git a/packages/ra-ui-materialui/src/Labeled.tsx b/packages/ra-ui-materialui/src/Labeled.tsx index 8b188280690..416080ac252 100644 --- a/packages/ra-ui-materialui/src/Labeled.tsx +++ b/packages/ra-ui-materialui/src/Labeled.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { ElementType, ReactElement } from 'react'; +import type { ElementType, ReactElement, ReactNode } from 'react'; import { Stack, type StackProps, @@ -45,6 +45,14 @@ export const Labeled = (inProps: LabeledProps) => { ...rest } = props; + const childrenProps = React.isValidElement(children) ? children.props : {}; + const isLabeled = React.isValidElement(children) + ? // @ts-ignore + children.type?.displayName === 'Labeled' + : false; + const shouldAddLabel = + label !== false && childrenProps.label !== false && !isLabeled; + return ( { })} {...rest} > - {label !== false && - children.props.label !== false && - typeof children.type !== 'string' && - // @ts-ignore - children.type?.displayName !== 'Labeled' && - // @ts-ignore - children.type?.displayName !== 'Labeled' ? ( + {shouldAddLabel ? ( { {...TypographyProps} > @@ -90,7 +92,7 @@ export const Labeled = (inProps: LabeledProps) => { Labeled.displayName = 'Labeled'; export interface LabeledProps extends StackProps { - children: ReactElement; + children: ReactNode; className?: string; color?: | ResponsiveStyleValue diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx index 183d1f2c255..ed00e3318aa 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx @@ -108,7 +108,11 @@ export const Offline = () => ( ); export const OfflineWithChildren = () => ( - + englishMessages)} + defaultTheme="light" + > @@ -117,11 +121,13 @@ export const OfflineWithChildren = () => ( + diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx index af67b19b2dc..7a572bfe383 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyField.stories.tsx @@ -8,7 +8,7 @@ import { } from 'ra-core'; import { Admin, ListGuesser, Resource } from 'react-admin'; import type { AdminProps } from 'react-admin'; -import { ThemeProvider, Box, Stack } from '@mui/material'; +import { ThemeProvider, Box, Stack, Typography } from '@mui/material'; import { createTheme } from '@mui/material/styles'; import fakeDataProvider from 'ra-data-fakerest'; import polyglotI18nProvider from 'ra-i18n-polyglot'; @@ -123,6 +123,40 @@ export const Basic = () => ( ); +const LoadChildrenOnDemand = ({ children }: { children: React.ReactNode }) => { + const [showChildren, setShowChildren] = React.useState(false); + const handleClick = () => { + setShowChildren(true); + }; + return showChildren ? ( + children + ) : ( +
+ + Don't forget to go offline first + + +
+ ); +}; + +export const Offline = () => ( + + + } + perPage={5} + > + + + + + + +); + export const WithSingleFieldList = () => ( diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 425041913f4..7c78f9994ce 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -48,6 +48,7 @@ import { import type { CommonInputProps } from './CommonInputProps'; import { InputHelperText } from './InputHelperText'; import { sanitizeInputRestProps } from './sanitizeInputRestProps'; +import { Offline } from '../Offline'; const defaultFilterOptions = createFilterOptions(); @@ -161,6 +162,7 @@ export const AutocompleteInput = < isRequired: isRequiredOverride, label, limitChoicesToValue, + loadingText = 'ra.message.loading', matchSuggestion, margin, fieldState: fieldStateOverride, @@ -168,6 +170,7 @@ export const AutocompleteInput = < formState: formStateOverride, multiple = false, noOptionsText, + offline = defaultOffline, onBlur, onChange, onCreate, @@ -197,6 +200,8 @@ export const AutocompleteInput = < const { allChoices, isPending, + isPaused, + isPlaceholderData, error: fetchError, resource, source, @@ -610,12 +615,22 @@ If you provided a React element for the optionText prop, you must also provide t const renderHelperText = !!fetchError || helperText !== false || invalid; const handleInputRef = useForkRef(field.ref, TextFieldProps?.inputRef); + return ( <> any; inputText?: (option: any) => string; + offline?: ReactNode; onChange?: ( // We can't know upfront what the value type will be value: Multiple extends true ? any[] : any, @@ -912,6 +931,7 @@ const areSelectedItemsEqual = ( }; const DefaultFilterToQuery = searchText => ({ q: searchText }); +const defaultOffline = ; declare module '@mui/material/styles' { interface ComponentNameToClassKey { diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx index 24f18ee9e59..8205dcdb5a4 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx @@ -1,10 +1,5 @@ import * as React from 'react'; -import { - DataProvider, - Form, - testDataProvider, - TestMemoryRouter, -} from 'ra-core'; +import { DataProvider, Form, TestMemoryRouter } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import { Admin, Resource } from 'react-admin'; @@ -29,23 +24,21 @@ const tags = [ { id: 2, name: 'Design' }, { id: 3, name: 'Painting' }, { id: 4, name: 'Photography' }, + { id: 5, name: 'Sculpture' }, + { id: 6, name: 'Urbanism' }, + { id: 7, name: 'Video' }, + { id: 8, name: 'Web' }, + { id: 9, name: 'Writing' }, + { id: 10, name: 'Other' }, ]; -const dataProvider = testDataProvider({ - // @ts-ignore - getList: () => - Promise.resolve({ - data: tags, - total: tags.length, - }), - // @ts-ignore - getMany: (resource, params) => { - console.log('getMany', resource, params); - return Promise.resolve({ - data: params.ids.map(id => tags.find(tag => tag.id === id)), - }); +const dataProvider = fakeRestProvider( + { + tags, }, -}); + process.env.NODE_ENV !== 'test', + process.env.NODE_ENV !== 'test' ? 300 : 0 +); const i18nProvider = polyglotI18nProvider(() => englishMessages); diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx index 4049e3deca5..483c0bcfb16 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.tsx @@ -100,15 +100,13 @@ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { const { isPaused, allChoices } = controllerProps; return isPaused && allChoices == null ? ( - offline ?? ( - - - - ) + + {offline ?? } + ) : ( diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx index cc2c6472adb..30229ea8fe5 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx @@ -126,24 +126,42 @@ const LoadChildrenOnDemand = ({ children }: { children: React.ReactNode }) => { ); }; -const BookEditOffline = () => ( - { - console.log(data); - }, - }} - > - - - - - - +export const Offline = ({ dataProvider = dataProviderWithAuthors }) => ( + + + + `${record.first_name} ${record.last_name}` + } + /> + { + console.log(data); + }, + }} + > + + + + + +
+ } + /> + + ); -export const Offline = ({ dataProvider = dataProviderWithAuthors }) => ( +export const CustomOffline = ({ dataProvider = dataProviderWithAuthors }) => ( ( `${record.first_name} ${record.last_name}` } /> - + { + console.log(data); + }, + }} + > + + + You're offline

} + /> +
+
+
+ } + /> ); diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx index 73e00a4bc5c..384e18d8021 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.tsx @@ -78,15 +78,13 @@ export const ReferenceInput = (props: ReferenceInputProps) => { - - - ) + + {offline ?? } + } > {children} From dbd8dd16b644360cdee8442e69728ed9d30908ad Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:05:26 +0200 Subject: [PATCH 53/58] Improve documentation --- docs/DataProviders.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 848648dd569..2cfbb4b715a 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -905,10 +905,10 @@ import { addOfflineSupportToQueryClient } from 'react-admin'; import { QueryClient } from '@tanstack/react-query'; import { dataProvider } from './dataProvider'; -export const queryClient = new QueryClient(); +const baseQueryClient = new QueryClient(); -const queryClientWithOfflineSupport = addOfflineSupportToQueryClient({ - queryClient, +export const queryClient = addOfflineSupportToQueryClient({ + queryClient: baseQueryClient, dataProvider, resources: ['posts', 'comments'], }); @@ -1007,14 +1007,14 @@ import { dataProvider } from './dataProvider'; const baseQueryClient = new QueryClient(); export const queryClient = addOfflineSupportToQueryClient({ - queryClient, + queryClient: baseQueryClient, dataProvider, resources: ['posts', 'comments'], }); queryClient.setMutationDefaults('banUser', { mutationFn: async (userId) => { - return dataProviderFn.banUser(userId); + return dataProvider.banUser(userId); }, }); ``` \ No newline at end of file From b8b13c52129e931a0eda140f655d3de601548984 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:09:56 +0200 Subject: [PATCH 54/58] Fix yarn.lock --- yarn.lock | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/yarn.lock b/yarn.lock index a0c44a43b5f..72cadabc668 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4355,6 +4355,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.81.2": + version: 5.81.2 + resolution: "@tanstack/query-core@npm:5.81.2" + checksum: 36a6bddec2e7512015bcfbb0d7b0876fab418de9e0ef21ad403598276960e0b7d53efd62832ce462738ad22d9883e31cb5403eafc65dfd9b2f6744c22a9d8e42 + languageName: node + linkType: hard + "@tanstack/query-devtools@npm:5.47.0": version: 5.47.0 resolution: "@tanstack/query-devtools@npm:5.47.0" @@ -4362,22 +4369,22 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-persist-client-core@npm:5.47.0": - version: 5.47.0 - resolution: "@tanstack/query-persist-client-core@npm:5.47.0" +"@tanstack/query-persist-client-core@npm:5.81.2": + version: 5.81.2 + resolution: "@tanstack/query-persist-client-core@npm:5.81.2" dependencies: - "@tanstack/query-core": "npm:5.47.0" - checksum: b82e532bd618fd0f4b57eb653f88f0777d36db872184b5cff8033772778f838960ffbf46174cc902cb2260d7065dda4ce6f9b216e6b5d13f67cabd425a66a884 + "@tanstack/query-core": "npm:5.81.2" + checksum: 65aebf52678cbadae81ec8bdf2764781cf975ed0eaf7ee37f77c536139da9f8c9220a1006c0ded50c1abd2d70ff0b76ed5e352dd4c6d3da782df240a2a3d3cbc languageName: node linkType: hard -"@tanstack/query-sync-storage-persister@npm:5.47.0": - version: 5.47.0 - resolution: "@tanstack/query-sync-storage-persister@npm:5.47.0" +"@tanstack/query-sync-storage-persister@npm:^5.47.0": + version: 5.81.2 + resolution: "@tanstack/query-sync-storage-persister@npm:5.81.2" dependencies: - "@tanstack/query-core": "npm:5.47.0" - "@tanstack/query-persist-client-core": "npm:5.47.0" - checksum: f82e1b68db259170711aaa5b76684d23131e9d272ffc78703583370823c21c06fedb8cd5e61f6df5228a369356b5527db8b6d9e467930374f942d1e70e34fea0 + "@tanstack/query-core": "npm:5.81.2" + "@tanstack/query-persist-client-core": "npm:5.81.2" + checksum: cba3d0c0bf032c5a4aac5c49f2ed18f2660a2ae3640741a299a71097682cd15847a58c986827f0a01310d5da407591159cc1ed0b1a8b258100644a98aa554b8a languageName: node linkType: hard @@ -4393,15 +4400,15 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-query-persist-client@npm:5.47.0": - version: 5.47.0 - resolution: "@tanstack/react-query-persist-client@npm:5.47.0" +"@tanstack/react-query-persist-client@npm:^5.47.0": + version: 5.81.2 + resolution: "@tanstack/react-query-persist-client@npm:5.81.2" dependencies: - "@tanstack/query-persist-client-core": "npm:5.47.0" + "@tanstack/query-persist-client-core": "npm:5.81.2" peerDependencies: - "@tanstack/react-query": ^5.47.0 + "@tanstack/react-query": ^5.81.2 react: ^18 || ^19 - checksum: 0bd0988f03811c5dcdc49c53f40a89495c1c11c9697eb15800d44c48ba626eb22fc421ca9348f84186406ec7652c10e36bde77790eddd14b934a38a90b488af4 + checksum: cd4176744c6a96295d9c6441e212420b34f83f10f82c59de87aaba0d918de27964fb1d3cd0d91d2cff2e94624fe978a9eb33a348980bf35a624ccb8e5cec0a2f languageName: node linkType: hard @@ -17908,10 +17915,10 @@ __metadata: "@hookform/devtools": "npm:^4.3.3" "@mui/icons-material": "npm:^5.16.12" "@mui/material": "npm:^5.16.12" - "@tanstack/query-sync-storage-persister": "npm:5.47.0" + "@tanstack/query-sync-storage-persister": "npm:^5.47.0" "@tanstack/react-query": "npm:^5.21.7" "@tanstack/react-query-devtools": "npm:^5.21.7" - "@tanstack/react-query-persist-client": "npm:5.47.0" + "@tanstack/react-query-persist-client": "npm:^5.47.0" "@vitejs/plugin-react": "npm:^4.3.4" jsonexport: "npm:^3.2.0" little-state-machine: "npm:^4.8.1" From a3b1da8badadf7499cd169d831c83962f9266e67 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:43:18 +0200 Subject: [PATCH 55/58] Document how to handle errors for resumed mutations --- docs/DataProviders.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 2cfbb4b715a..6dbe6c8a1cf 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -1017,4 +1017,41 @@ queryClient.setMutationDefaults('banUser', { return dataProvider.banUser(userId); }, }); -``` \ No newline at end of file +``` + +## Handling Errors For Resumed Mutations + +If you enabled offline support, users might trigger mutations while being actually offline. When they're back online, react-query will _resume_ those mutations and they might fail for other reasons (server side validation or errors). However, as users might have navigated away from the page that triggered the mutation, they won't see any notification. + +To handle this scenario, you must register default `onError` side effects for all mutations (react-admin default ones or custom). If you want to leverage react-admin notifications, you can use a custom layout: + +```tsx +// in src/Layout.tsx +export const MyLayout = ({ children }: { children: React.ReactNode }) => { + const queryClient = useQueryClient(); + const notify = useNotify(); + + React.useEffect(() => { + const mutationKeyFilter = []; // An empty array targets all mutations + queryClient.setMutationDefaults([], { + onSettled(data, error) { + if (error) { + notify(error.message, { type: 'error' }); + } + }, + }); + }, [queryClient, notify]); + + return ( + + {children} + + ); +} +``` + +Note that this simple example will only show the error message as it was received. Users may not have the context to understand the error (what record or operation it relates to). +Here are some ideas for a better user experience: + +- make sure your messages allow users to go to the pages related to the errors (you can leverage [custom notifications](./useNotify.md#custom-notification-content) for that) +- store the notifications somewhere (server side or not) and show them in a custom page with proper links, etc. From 4413807d61beaf34f5d47af73ac0d5f194d551e7 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:09:16 +0200 Subject: [PATCH 56/58] Dedupe @tanstack/query-core --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 72cadabc668..f6e8876baa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4348,14 +4348,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.47.0": - version: 5.47.0 - resolution: "@tanstack/query-core@npm:5.47.0" - checksum: 2d2378dbde2b0610b6356fcdb56904aa9d41c140c17ceb55e257b918c8555484ff36743a6a37768575631be96b9291eedc53723cd80326783095499cb97db049 - languageName: node - linkType: hard - -"@tanstack/query-core@npm:5.81.2": +"@tanstack/query-core@npm:5.47.0, @tanstack/query-core@npm:5.81.2": version: 5.81.2 resolution: "@tanstack/query-core@npm:5.81.2" checksum: 36a6bddec2e7512015bcfbb0d7b0876fab418de9e0ef21ad403598276960e0b7d53efd62832ce462738ad22d9883e31cb5403eafc65dfd9b2f6744c22a9d8e42 From 3fda18b7d6e3c89060de8ad0e7021e52015d2e8a Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:10:59 +0200 Subject: [PATCH 57/58] Improve DataProviders documentation --- docs/DataProviders.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DataProviders.md b/docs/DataProviders.md index 6dbe6c8a1cf..f6cacc58509 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -1021,7 +1021,7 @@ queryClient.setMutationDefaults('banUser', { ## Handling Errors For Resumed Mutations -If you enabled offline support, users might trigger mutations while being actually offline. When they're back online, react-query will _resume_ those mutations and they might fail for other reasons (server side validation or errors). However, as users might have navigated away from the page that triggered the mutation, they won't see any notification. +If you enabled offline support, users might trigger mutations while being actually offline. When they're back online, TanStack Query will _resume_ those mutations and they might fail for other reasons (server side validation or errors). However, as users might have navigated away from the page that triggered the mutation, they won't see any notification. To handle this scenario, you must register default `onError` side effects for all mutations (react-admin default ones or custom). If you want to leverage react-admin notifications, you can use a custom layout: @@ -1033,7 +1033,7 @@ export const MyLayout = ({ children }: { children: React.ReactNode }) => { React.useEffect(() => { const mutationKeyFilter = []; // An empty array targets all mutations - queryClient.setMutationDefaults([], { + queryClient.setMutationDefaults(mutationKeyFilter, { onSettled(data, error) { if (error) { notify(error.message, { type: 'error' }); From 66416c1e38f7a01d661c6de7c0ad738d6b93639d Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:33:37 +0200 Subject: [PATCH 58/58] Fix following tanstack/query-core upgrade --- packages/ra-core/src/auth/useAuthState.ts | 2 +- packages/ra-core/src/auth/useCanAccess.ts | 2 +- packages/ra-core/src/auth/usePermissions.ts | 5 ++++- .../src/controller/input/useReferenceInputController.ts | 2 +- .../src/dataProvider/addOfflineSupportToQueryClient.ts | 2 +- packages/ra-core/src/dataProvider/useGetList.ts | 2 +- packages/ra-core/src/dataProvider/useGetManyReference.ts | 2 +- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/ra-core/src/auth/useAuthState.ts b/packages/ra-core/src/auth/useAuthState.ts index aa75ef8d5cd..868da334f94 100644 --- a/packages/ra-core/src/auth/useAuthState.ts +++ b/packages/ra-core/src/auth/useAuthState.ts @@ -150,7 +150,7 @@ const useAuthState = ( return authProvider != null ? result - : (noAuthProviderQueryResult as UseAuthStateResult); + : (noAuthProviderQueryResult as unknown as UseAuthStateResult); }; type UseAuthStateOptions = Omit< diff --git a/packages/ra-core/src/auth/useCanAccess.ts b/packages/ra-core/src/auth/useCanAccess.ts index 63db7906666..ead40b597dc 100644 --- a/packages/ra-core/src/auth/useCanAccess.ts +++ b/packages/ra-core/src/auth/useCanAccess.ts @@ -93,7 +93,7 @@ export const useCanAccess = < return authProviderHasCanAccess ? result - : (emptyQueryObserverResult as UseCanAccessResult); + : (emptyQueryObserverResult as unknown as UseCanAccessResult); }; const emptyQueryObserverResult = { diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts index eddf21142c1..9ef83079a08 100644 --- a/packages/ra-core/src/auth/usePermissions.ts +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -108,7 +108,10 @@ const usePermissions = ( ); return !authProvider || !authProvider.getPermissions - ? (fakeQueryResult as UsePermissionsResult) + ? (fakeQueryResult as unknown as UsePermissionsResult< + PermissionsType, + ErrorType + >) : result; }; diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index e183c62c3bb..df5757c948b 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -118,8 +118,8 @@ export const useReferenceInputController = ( } = useReference({ id: currentValue, reference, - // @ts-ignore the types of the queryOptions for the getMAny and getList are not compatible options: { + // @ts-ignore the types of the queryOptions for the getMany and getList are not compatible enabled: currentValue != null && currentValue !== '', meta, ...otherQueryOptions, diff --git a/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts index 3916a6a4037..360b17fa2bc 100644 --- a/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts +++ b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts @@ -92,7 +92,7 @@ export const addOfflineSupportToQueryClient = ({ resources.forEach(resource => { DataProviderMutations.forEach(mutation => { queryClient.setMutationDefaults([resource, mutation], { - mutationFn: async params => { + mutationFn: async (params: any) => { const dataProviderFn = dataProvider[mutation] as Function; return dataProviderFn.apply(dataProviderFn, ...params); }, diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index aa24430d175..e16296dfb3a 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -176,7 +176,7 @@ export const useGetList = < } : result, [result] - ) as UseQueryResult & { + ) as unknown as UseQueryResult & { total?: number; pageInfo?: { hasNextPage?: boolean; diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index 001e930fc6b..c6639a9147d 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -155,7 +155,7 @@ export const useGetManyReference = < } : result, [result] - ) as UseQueryResult & { + ) as unknown as UseQueryResult & { total?: number; pageInfo?: { hasNextPage?: boolean;