diff --git a/cypress/e2e/edit.cy.js b/cypress/e2e/edit.cy.js index 9f85eecb013..b53aabaf5ba 100644 --- a/cypress/e2e/edit.cy.js +++ b/cypress/e2e/edit.cy.js @@ -47,7 +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.setInputValue('input', 'title', 'Lorem Ipsum{enter}'); + EditPostPage.setInputValue( + 'input', + 'title', + 'Lorem Ipsum again{enter}' + ); cy.url().should('match', /\/#\/posts$/); }); 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', diff --git a/docs/DataProviders.md b/docs/DataProviders.md index c1c618d8d69..f6cacc58509 100644 --- a/docs/DataProviders.md +++ b/docs/DataProviders.md @@ -888,3 +888,170 @@ 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. + +## Offline Support + +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: + +```ts +// in src/queryClient.ts +import { addOfflineSupportToQueryClient } from 'react-admin'; +import { QueryClient } from '@tanstack/react-query'; +import { dataProvider } from './dataProvider'; + +const baseQueryClient = new QueryClient(); + +export const queryClient = addOfflineSupportToQueryClient({ + queryClient: baseQueryClient, + 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 %} + +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 +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 + + {MutationModes.map(mutationMode => ( + handleMenuItemClick(mutationMode)} + > + {mutationMode} + + ))} + + + ); +}; 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-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/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx index 15b72f8f8d4..bb49bfbcdff 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,23 @@ const useDeleteWithConfirmController = < onSuccess: () => { setOpen(false); notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage != null + ? 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..2cee6be3cf0 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,31 @@ 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 != null + ? 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 9ee07aee7f8..e8e31d094e8 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 d345d5227ab..57791912da1 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -23,6 +23,7 @@ import { useResourceContext, useGetResourceLabel, useGetRecordRepresentation, + useIsOffine, } from '../../core'; import { SaveContextValue, @@ -113,6 +114,7 @@ export const useEditController = < error, isLoading, isFetching, + isPaused, isPending, refetch, } = useGetOne( @@ -173,6 +175,7 @@ export const useEditController = < const recordCached = { id, previousData: record }; + const isOffline = useIsOffine(); const [update, { isPending: saving }] = useUpdate( resource, recordCached, @@ -181,16 +184,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) => { @@ -289,6 +302,7 @@ export const useEditController = < error, isFetching, isLoading, + isPaused, isPending, mutationMode, record, @@ -324,6 +338,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/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/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/input/ReferenceInputBase.tsx b/packages/ra-core/src/controller/input/ReferenceInputBase.tsx index d0425f2a18b..9c0ef85af64 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,11 @@ export const ReferenceInputBase = (props: ReferenceInputBaseProps) => { filter, }); - return ( + const { isPaused, allChoices } = controllerProps; + + return isPaused && allChoices == null ? ( + offline + ) : ( {children} @@ -91,4 +96,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..fa883c58b21 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -61,7 +61,9 @@ export const useReferenceArrayInputController = < error: errorGetMany, isLoading: isLoadingGetMany, isFetching: isFetchingGetMany, + isPaused: isPausedGetMany, isPending: isPendingGetMany, + isPlaceholderData: isPlaceholderDataGetMany, refetch: refetchGetMany, } = useGetManyAggregate( reference, @@ -99,7 +101,9 @@ export const useReferenceArrayInputController = < error: errorGetList, isLoading: isLoadingGetList, isFetching: isFetchingGetList, + isPaused: isPausedGetList, isPending: isPendingGetList, + isPlaceholderData: isPlaceholderDataGetList, refetch: refetchGetMatching, } = useGetList( reference, @@ -153,7 +157,9 @@ export const useReferenceArrayInputController = < hideFilter: paramsModifiers.hideFilter, isFetching: isFetchingGetMany || isFetchingGetList, 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/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 7ddbced53d2..df5757c948b 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -79,11 +79,12 @@ export const useReferenceInputController = ( // fetch possible values const { - data: possibleValuesData = [], + data: possibleValuesData, total, pageInfo, isFetching: isFetchingPossibleValues, isLoading: isLoadingPossibleValues, + isPaused: isPausedPossibleValues, isPending: isPendingPossibleValues, error: errorPossibleValues, refetch: refetchGetList, @@ -112,12 +113,13 @@ export const useReferenceInputController = ( error: errorReference, isLoading: isLoadingReference, isFetching: isFetchingReference, + isPaused: isPausedReference, isPending: isPendingReference, } = 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, @@ -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 @@ -140,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(); @@ -176,7 +192,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/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts index 8214e3ba9d0..23662d68042 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.ts +++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts @@ -102,7 +102,9 @@ export const useInfiniteListController = < total, error, isLoading, + isPaused, isPending, + isPlaceholderData, isFetching, hasNextPage, hasPreviousPage, @@ -204,7 +206,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/useListContextWithProps.ts b/packages/ra-core/src/controller/list/useListContextWithProps.ts index 12afb5b5e0f..34d88b2ca7d 100644 --- a/packages/ra-core/src/controller/list/useListContextWithProps.ts +++ b/packages/ra-core/src/controller/list/useListContextWithProps.ts @@ -80,7 +80,9 @@ const extractListContextProps = ({ hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect, onSelectAll, onToggleItem, @@ -107,7 +109,9 @@ const extractListContextProps = ({ hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect, onSelectAll, onToggleItem, diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 50b56a53142..036a1f8d3fb 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -114,10 +114,10 @@ export const useListController = < error, isLoading, isFetching, - isPending, - refetch, isPaused, + isPending, isPlaceholderData, + refetch, } = useGetList( resource, { @@ -216,7 +216,9 @@ export const useListController = < hideFilter: queryModifiers.hideFilter, isFetching, isLoading, + isPaused, isPending, + isPlaceholderData, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, @@ -542,6 +544,8 @@ export interface ListControllerBaseResult { hasPreviousPage?: boolean; isFetching?: boolean; isLoading?: boolean; + isPaused?: boolean; + isPlaceholderData?: boolean; } export interface ListControllerLoadingResult diff --git a/packages/ra-core/src/controller/show/useShowController.ts b/packages/ra-core/src/controller/show/useShowController.ts index 4f523a8b126..c056939e565 100644 --- a/packages/ra-core/src/controller/show/useShowController.ts +++ b/packages/ra-core/src/controller/show/useShowController.ts @@ -97,6 +97,7 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, refetch, } = useGetOne( @@ -159,6 +160,7 @@ export const useShowController = < error, isLoading, isFetching, + isPaused, isPending, record, refetch, @@ -180,6 +182,7 @@ export interface ShowControllerBaseResult { defaultTitle?: string; isFetching: boolean; isLoading: boolean; + isPaused: boolean; resource: string; record?: RecordType; refetch: UseGetOneHookValue['refetch']; diff --git a/packages/ra-core/src/controller/useReference.spec.tsx b/packages/ra-core/src/controller/useReference.spec.tsx index e8b991c781c..4c233df6335 100644 --- a/packages/ra-core/src/controller/useReference.spec.tsx +++ b/packages/ra-core/src/controller/useReference.spec.tsx @@ -130,7 +130,9 @@ describe('useReference', () => { referenceRecord: undefined, isFetching: true, isLoading: true, + isPaused: false, isPending: true, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -138,7 +140,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: false, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -170,7 +174,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: true, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); @@ -178,7 +184,9 @@ describe('useReference', () => { referenceRecord: { id: 1, title: 'foo' }, isFetching: false, isLoading: false, + isPaused: false, isPending: false, + isPlaceholderData: false, error: null, refetch: expect.any(Function), }); diff --git a/packages/ra-core/src/controller/useReference.ts b/packages/ra-core/src/controller/useReference.ts index b52526226c9..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-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-core/src/dataProvider/addOfflineSupportToQueryClient.ts b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts new file mode 100644 index 00000000000..360b17fa2bc --- /dev/null +++ b/packages/ra-core/src/dataProvider/addOfflineSupportToQueryClient.ts @@ -0,0 +1,104 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { DataProviderMutations } 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 { 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'; + * import { posts } from './posts'; + * import { comments } from './comments'; + * + * const localStoragePersister = createSyncStoragePersister({ + * storage: window.localStorage, + * }); + * + * const queryClient = addOfflineSupportToQueryClient({ + * queryClient: new QueryClient(), + * dataProvider, + * resources: ['posts', 'comments'], + * }); + * + * 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 { 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 { dataProvider } from './dataProvider'; + * import { posts } from './posts'; + * import { comments } from './comments'; + * + * const localStoragePersister = createSyncStoragePersister({ + * storage: window.localStorage, + * }); + * + * const queryClient = addOfflineSupportToQueryClient({ + * queryClient: new QueryClient(), + * dataProvider, + * resources: ['posts', 'comments'], + * mutations: [...reactAdminMutations, 'myCustomMutation'], + * }); + * + * 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 => { + DataProviderMutations.forEach(mutation => { + queryClient.setMutationDefaults([resource, mutation], { + mutationFn: async (params: any) => { + const dataProviderFn = dataProvider[mutation] as Function; + return dataProviderFn.apply(dataProviderFn, ...params); + }, + }); + }); + }); + + return queryClient; +}; diff --git a/packages/ra-core/src/dataProvider/dataFetchActions.ts b/packages/ra-core/src/dataProvider/dataFetchActions.ts index 249d90a7647..d9c1eadcea0 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 DataProviderMutations = [ + '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 462e36e2ace..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); @@ -152,6 +156,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 664778de44e..1f261467d25 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -199,6 +199,7 @@ export const useUpdateMany = < MutationError, Partial> >({ + mutationKey: [resource, 'updateMany', params], mutationFn: ({ resource: callTimeResource = resource, ids: callTimeIds = paramsRef.current.ids, diff --git a/packages/ra-core/src/dataTable/DataTableBase.tsx b/packages/ra-core/src/dataTable/DataTableBase.tsx index b16e732da00..a89e4c50401 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, @@ -39,7 +40,9 @@ export const DataTableBase = function DataTable< const { sort, data, + isPaused, isPending, + isPlaceholderData, onSelect, onToggleItem, selectedIds, @@ -145,7 +148,7 @@ export const DataTableBase = function DataTable< ] ); - if (isPending === true) { + if (isPending && !isPaused) { return loading; } @@ -154,7 +157,11 @@ export const DataTableBase = function DataTable< * displaying the table header with zero data rows, * the DataTable 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; } @@ -162,6 +169,14 @@ export const DataTableBase = function DataTable< return null; } + if (isPaused && (isPlaceholderData || data == null)) { + 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), @@ -206,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-core/src/form/choices/ChoicesContext.ts b/packages/ra-core/src/form/choices/ChoicesContext.ts index 6e0b53dee80..a420a6351b0 100644 --- a/packages/ra-core/src/form/choices/ChoicesContext.ts +++ b/packages/ra-core/src/form/choices/ChoicesContext.ts @@ -20,6 +20,8 @@ export type ChoicesContextBaseValue = { hideFilter: (filterName: string) => void; 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-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 0e665685b0f..389d4f150d2 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,9 @@ 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.', + 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 fb450792c02..06e14d7f8b8 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,9 @@ 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.', + 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/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/Offline.tsx b/packages/ra-ui-materialui/src/Offline.tsx new file mode 100644 index 00000000000..fc6a711f189 --- /dev/null +++ b/packages/ra-ui-materialui/src/Offline.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { + Alert, + AlertProps, + ComponentsOverrides, + styled, + Typography, +} from '@mui/material'; +import { useGetResourceLabel, useResourceContext, useTranslate } from 'ra-core'; +import clsx from 'clsx'; + +export const Offline = (props: Offline) => { + const { icon, message: messageProp, variant = 'standard', ...rest } = 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 Omit { + resource?: string; + message?: string; + variant?: AlertProps['variant'] | 'inline'; +} + +const PREFIX = 'RaOffline'; +export const OfflineClasses = { + root: `${PREFIX}-root`, + inline: `${PREFIX}-inline`, +}; + +const Root = styled(Alert, { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(() => ({ + [`&.${OfflineClasses.inline}`]: { + border: 'none', + display: 'inline-flex', + padding: 0, + margin: 0, + }, +})); + +declare module '@mui/material/styles' { + interface ComponentNameToClassKey { + [PREFIX]: 'root'; + } + + interface ComponentsPropsList { + [PREFIX]: Partial; + } + + interface Components { + [PREFIX]?: { + defaultProps?: ComponentsPropsList[typeof PREFIX]; + styleOverrides?: ComponentsOverrides< + Omit + >[typeof PREFIX]; + }; + } +} diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithConfirmButton.tsx index f7b149d78e8..bdc1c4d89c4 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,23 @@ export const BulkDeleteWithConfirmButton = ( onSuccess: () => { refresh(); notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage != null + ? 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 e8950e0b23a..7f2e258589a 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,23 @@ export const BulkDeleteWithUndoButton = ( { onSuccess: () => { notify( - successMessage ?? - `resources.${resource}.notifications.deleted`, + successMessage != null + ? 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 6358ba332ab..ae5f69b7930 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 e612ceb30cf..35b8a17198f 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 daa8a36e0e9..2cc2843950e 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, useResourceTranslation, } from 'ra-core'; @@ -39,6 +40,7 @@ export const UpdateWithConfirmButton = < const translate = useTranslate(); const resource = useResourceContext(props); const [isOpen, setOpen] = useState(false); + const isOffline = useIsOffine(); const record = useRecordContext(props); const { @@ -61,14 +63,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: MutationOptionsError) => { notify( diff --git a/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/UpdateWithUndoButton.tsx index 8a5da5361df..275915b441a 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, useGetRecordRepresentation, useResourceTranslation, } from 'ra-core'; @@ -31,6 +32,7 @@ export const UpdateWithUndoButton = (inProps: UpdateWithUndoButtonProps) => { const notify = useNotify(); const resource = useResourceContext(props); const refresh = useRefresh(); + const isOffline = useIsOffine(); const { data, @@ -71,14 +73,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/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 26cbbae0eec..6573a4adec5 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, @@ -13,9 +13,11 @@ import { useEditContext, useResourceDefinition } from 'ra-core'; import { EditActions } from './EditActions'; import { Title } from '../layout'; +import { Offline } from '../Offline'; import { EditProps } from './Edit'; const defaultActions = ; +const defaultOffline = ; export const EditView = (props: EditViewProps) => { const { @@ -24,13 +26,25 @@ export const EditView = (props: 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 && record == null && offline) { + return ( + +
+ {offline} +
+
+ ); + } const finalActions = typeof actions === 'undefined' && hasShow ? defaultActions : actions; @@ -64,11 +78,12 @@ export const EditView = (props: EditViewProps) => { export interface EditViewProps extends Omit, 'id' | 'title'> { - actions?: ReactElement | false; - aside?: ReactElement; + actions?: ReactNode; + aside?: ReactNode; + offline?: ReactNode; component?: ElementType; emptyWhileLoading?: boolean; - title?: string | ReactElement | false; + title?: ReactNode; sx?: SxProps; } diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 844f6a1e7cc..aae98b82c0a 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, @@ -11,9 +11,11 @@ import clsx from 'clsx'; import { useShowContext, useResourceDefinition } from 'ra-core'; import { ShowActions } from './ShowActions'; import { Title } from '../layout'; +import { Offline } from '../Offline'; import { ShowProps } from './Show'; const defaultActions = ; +const defaultOffline = ; export const ShowView = (props: ShowViewProps) => { const { @@ -23,13 +25,24 @@ export const ShowView = (props: 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 && record == null && offline) { + return ( + +
+ {offline} +
+
+ ); + } + const finalActions = typeof actions === 'undefined' && hasEdit ? defaultActions : actions; @@ -60,11 +73,12 @@ export const ShowView = (props: 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; } diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx index 532fc1c7e03..ed00e3318aa 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,70 @@ 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 = () => ( + englishMessages)} + defaultTheme="light" + > + + + + + + + + + + + + + + + + + +); + 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/field/ReferenceField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx index d80e2e7f90e..fe0f2b7b616 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, Themed, } from './ReferenceField.stories'; import { TextField } from './TextField'; @@ -460,29 +461,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.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx index 6da8201be34..6084f763978 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceField.stories.tsx @@ -124,6 +124,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 87c3098492b..412bf802f12 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, @@ -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'; @@ -25,7 +25,7 @@ import { LinearProgress } from '../layout'; import { Link } from '../Link'; import type { FieldProps } from './types'; import { genericMemo } from './genericMemo'; -import { visuallyHidden } from '@mui/utils'; +import { Offline } from '../Offline'; /** * Fetch reference record, and render its representation, or delegate rendering to child component. @@ -69,13 +69,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; } @@ -106,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, @@ -113,25 +113,37 @@ export const ReferenceFieldView = < >( props: ReferenceFieldViewProps ) => { - const { children, className, emptyText, reference, sx, ...rest } = - useThemeProps({ - props: props, - name: PREFIX, - }); - const { error, link, isLoading, referenceRecord } = + const { + children, + className, + emptyText, + offline = defaultOffline, + reference, + sx, + ...rest + } = useThemeProps({ + props: props, + name: PREFIX, + }); + const { error, link, isLoading, isPaused, referenceRecord } = useReferenceFieldContext(); const getRecordRepresentation = useGetRecordRepresentation(reference); - const translate = useTranslate(); if (error) { return ( -
+ - - {typeof error === 'string' ? error : error?.message} - -
+ + + Server communication error + + + ); } // We explicitly check isLoading here as the record may not have an id for the reference, @@ -141,9 +153,12 @@ export const ReferenceFieldView = < return ; } if (!referenceRecord) { + if (isPaused) { + return offline; + } return emptyText ? ( - {emptyText && translate(emptyText, { _: emptyText })} + {emptyText} ) : null; } @@ -190,6 +205,7 @@ export interface ReferenceFieldViewProps< > extends FieldProps, Omit, 'link'> { children?: ReactNode; + offline?: ReactNode; reference: string; resource?: string; translateChoice?: Function | boolean; diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx index 313b871c57d..80bd8c3ec78 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx @@ -18,7 +18,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 8f576fabe8a..09cc60fd1c8 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, @@ -6,9 +6,15 @@ import { useCreatePath, SortPayload, RaRecord, + Translate, } from 'ra-core'; +import { + Typography, + TypographyProps, + CircularProgress, + Stack, +} from '@mui/material'; import clsx from 'clsx'; -import { Typography, TypographyProps, CircularProgress } from '@mui/material'; import { ComponentsOverrides, styled, @@ -19,6 +25,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 @@ -49,6 +58,7 @@ export const ReferenceManyCount = ( filter, sort, link, + offline = defaultOffline, resource, source = 'id', timeout = 1000, @@ -58,7 +68,7 @@ export const ReferenceManyCount = ( const oneSecondHasPassed = useTimeout(timeout); const createPath = useCreatePath(); - const { isPending, error, total } = + const { isPaused, isPending, error, total } = useReferenceManyFieldController({ filter, sort, @@ -66,23 +76,37 @@ 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 = ( + + + + + Server communication error + + + + ); + } return ( extends Omit, 'source'>, Omit { + offline?: ReactNode; reference: string; source?: string; target: string; 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/field/ReferenceOneField.tsx b/packages/ra-ui-materialui/src/field/ReferenceOneField.tsx index ab2400cbbe6..f895eb4d322 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 { @@ -17,6 +17,8 @@ import { import { FieldProps } from './types'; import { ReferenceFieldView } from './ReferenceField'; +import { Offline } from '../Offline'; + import { sanitizeFieldRestProps } from './sanitizeFieldRestProps'; import { useThemeProps } from '@mui/material/styles'; @@ -47,6 +49,7 @@ export const ReferenceOneField = < source = 'id', target, emptyText, + offline = defaultOffline, sort, filter, link, @@ -91,11 +94,20 @@ export const ReferenceOneField = < emptyText ) : null; - return !record || + if ( + !record || (!controllerProps.isPending && - controllerProps.referenceRecord == null) ? ( - empty - ) : ( + !controllerProps.isPaused && + controllerProps.referenceRecord == null) + ) { + return empty; + } + + if (controllerProps.isPaused && controllerProps.referenceRecord == null) { + return offline; + } + + return ( @@ -123,7 +135,8 @@ export interface ReferenceOneFieldProps< source?: string; filter?: any; link?: LinkToType; - emptyText?: string | ReactElement; + emptyText?: ReactNode; + offline?: ReactNode; queryOptions?: Omit< UseQueryOptions<{ data: ReferenceRecordType[]; @@ -138,6 +151,8 @@ export interface ReferenceOneFieldProps< // leading to an incorrect sort indicator in a datagrid header ReferenceOneField.sortable = false; +const defaultOffline = ; + const PREFIX = 'RaReferenceOneField'; declare module '@mui/material/styles' { 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'; 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/InPlaceEditor/InPlaceEditor.spec.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx index b9bc471acdc..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'); 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', { diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx index 19830fb0f19..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'; @@ -19,6 +14,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' }; @@ -28,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); @@ -74,6 +68,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( @@ -94,8 +97,17 @@ export const ReferenceArrayInput = (props: ReferenceArrayInputProps) => { sort, filter, }); + const { isPaused, allChoices } = controllerProps; - return ( + return isPaused && allChoices == null ? ( + + {offline ?? } + + ) : ( {children} @@ -110,7 +122,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..30229ea8fe5 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.stories.tsx @@ -109,6 +109,94 @@ 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 + + +
+ ); +}; + +export const Offline = ({ dataProvider = dataProviderWithAuthors }) => ( + + + + `${record.first_name} ${record.last_name}` + } + /> + { + console.log(data); + }, + }} + > + + + + + + + } + /> + + +); + +export const CustomOffline = ({ dataProvider = dataProviderWithAuthors }) => ( + + + + `${record.first_name} ${record.last_name}` + } + /> + { + console.log(data); + }, + }} + > + + + You're offline

} + /> +
+
+ + } + /> +
+
+); + 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..384e18d8021 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,22 @@ export const ReferenceInput = (props: ReferenceInputProps) => { ); } - return {children}; + return ( + + {offline ?? } + + } + > + {children} + + ); }; const defaultChildren = ; @@ -82,5 +99,6 @@ export interface ReferenceInputProps extends ReferenceInputBaseProps { * Call validate on the child component instead */ validate?: never; + offline?: ReactNode; [key: string]: any; } diff --git a/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx b/packages/ra-ui-materialui/src/layout/LoadingIndicator.tsx index 99007ab5bf7..4bde713a0e3 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,53 @@ 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}
); }; 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; } diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 90189f61f78..6e1296d610f 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -147,10 +147,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 e6cd323b79f..2fc4eb65fd6 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -42,7 +42,9 @@ export const ListView = ( defaultTitle, data, error, + isPaused, isPending, + isPlaceholderData, filterValues, resource, total, @@ -72,6 +74,8 @@ export const ListView = ( empty !== false &&
{empty}
; const shouldRenderEmptyPage = + !isPaused && + !isPlaceholderData && !error && // the list is not loading data for the first time !isPending && diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index afb4c1d69d8..3d7da1ad806 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -23,7 +23,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'; @@ -33,6 +33,7 @@ import { SimpleListItem, type SimpleListItemProps, } from './SimpleListItem'; +import { Offline } from '../../Offline'; /** * The component renders a list of records as a Material UI . @@ -85,6 +86,7 @@ export const SimpleList = ( leftAvatar, leftIcon, linkType, + offline = DefaultOffline, rowClick, primaryText, rightAvatar, @@ -97,10 +99,10 @@ export const SimpleList = ( resource, ...rest } = props; - const { data, isPending, total } = + const { data, isPaused, isPending, isPlaceholderData, total } = useListContextWithProps(props); - if (isPending === true) { + if (isPending === true && !isPaused) { return ( ( ); } - if (data == null || data.length === 0 || total === 0) { + if ( + (data == null || data.length === 0 || total === 0) && + !isPaused && + !isPlaceholderData + ) { if (empty) { return empty; } @@ -120,9 +126,17 @@ export const SimpleList = ( return null; } + if (isPaused && (isPlaceholderData || data == null)) { + 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; @@ -285,6 +300,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..5b20cce8548 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 de5e681b4a3..96b33f9c194 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,26 +64,39 @@ export const SingleFieldList = (inProps: SingleFieldListProps) => { className, children, empty, + offline = DefaultOffline, linkType = 'edit', gap = 1, 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(); - if (isPending === true) { + if (isPending && !isPaused) { return ; } - if (data == null || data.length === 0 || total === 0) { + if ( + (data == null || data.length === 0 || total === 0) && + !isPaused && + !isPlaceholderData + ) { if (empty) { return empty; } return null; } + if (isPaused && (isPlaceholderData || total == null)) { + if (offline) { + return offline; + } + + return null; + } return ( { className={className} {...sanitizeListRestProps(rest)} > - {data.map((record, rowIndex) => { + {data?.map((record, rowIndex) => { const resourceLinkPath = !linkType ? false : createPath({ @@ -135,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 @@ -193,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 4d6f2fe95b2..beca48005d6 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, @@ -159,7 +161,9 @@ export const Datagrid: React.ForwardRefExoticComponent< const { sort, data, + isPaused, isPending, + isPlaceholderData, onSelect, onToggleItem, selectedIds, @@ -217,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. * @@ -610,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/yarn.lock b/yarn.lock index cd71595216b..f6e8876baa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,7 +32,43 @@ __metadata: languageName: node linkType: hard -"@apollo/client@npm:^3.12.4, @apollo/client@npm:^3.3.19, @apollo/client@npm:^3.9.11": +"@apollo/client@npm:^3.12.11": + version: 3.13.8 + resolution: "@apollo/client@npm:3.13.8" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.1.1" + "@wry/caches": "npm:^1.0.0" + "@wry/equality": "npm:^0.5.6" + "@wry/trie": "npm:^0.5.0" + graphql-tag: "npm:^2.12.6" + hoist-non-react-statics: "npm:^3.3.2" + optimism: "npm:^0.18.0" + prop-types: "npm:^15.7.2" + rehackt: "npm:^0.1.0" + symbol-observable: "npm:^4.0.0" + ts-invariant: "npm:^0.10.3" + tslib: "npm:^2.3.0" + zen-observable-ts: "npm:^1.2.5" + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: 0e5032c1ae1dbef72a01f87af06b84bf505d60e71eba7cb9f20f8284778d8ead65fc1b7eacc570eccb8d045577d7194e38401fbfbdf56c197e159ca91ef11755 + languageName: node + linkType: hard + +"@apollo/client@npm:^3.12.4, @apollo/client@npm:^3.3.19": version: 3.12.4 resolution: "@apollo/client@npm:3.12.4" dependencies: @@ -2010,43 +2046,43 @@ __metadata: languageName: node linkType: hard -"@graphql-tools/merge@npm:^9.0.3": - version: 9.0.3 - resolution: "@graphql-tools/merge@npm:9.0.3" +"@graphql-tools/merge@npm:^9.0.24": + version: 9.0.24 + resolution: "@graphql-tools/merge@npm:9.0.24" dependencies: - "@graphql-tools/utils": "npm:^10.0.13" + "@graphql-tools/utils": "npm:^10.8.6" tslib: "npm:^2.4.0" peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - checksum: ce2a6763488dbeeb778824780037ce5a00fd8c4a6337078d52c4fb4bcac28759b801ede280014d281472ee92416114e4c0eca621c618db617cb351df7d751570 + checksum: 04e2b402bfc05f844a66bd2c687b7aac1c61e321dceb655e698b11044247bd5940ba9d684ff518924b697b943c1f0785ac8d1ac864397dd8f59e8c823efa5376 languageName: node linkType: hard -"@graphql-tools/schema@npm:^10.0.3": - version: 10.0.3 - resolution: "@graphql-tools/schema@npm:10.0.3" +"@graphql-tools/schema@npm:^10.0.18": + version: 10.0.23 + resolution: "@graphql-tools/schema@npm:10.0.23" dependencies: - "@graphql-tools/merge": "npm:^9.0.3" - "@graphql-tools/utils": "npm:^10.0.13" + "@graphql-tools/merge": "npm:^9.0.24" + "@graphql-tools/utils": "npm:^10.8.6" tslib: "npm:^2.4.0" - value-or-promise: "npm:^1.0.12" peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - checksum: 420bfa29d00927da085a3e521d7d6de5694f3abcdf5ba18655cc2a6b6145816d74503b13ba3ea15c7c65411023c9d81cfb73e7d49aa35ccfb91943f16ab9db8f + checksum: f8b4dcc4751bde2e41e2fd7cafc0b01c6d69e0eee7022918fbb372695358138a95582fd6cf83dff13f98665b19f9ad234d88ffcd4e6969cb70ec2884eb4c805c languageName: node linkType: hard -"@graphql-tools/utils@npm:^10.0.13": - version: 10.1.3 - resolution: "@graphql-tools/utils@npm:10.1.3" +"@graphql-tools/utils@npm:^10.8.6": + version: 10.8.6 + resolution: "@graphql-tools/utils@npm:10.8.6" dependencies: "@graphql-typed-document-node/core": "npm:^3.1.1" - cross-inspect: "npm:1.0.0" - dset: "npm:^3.1.2" + "@whatwg-node/promise-helpers": "npm:^1.0.0" + cross-inspect: "npm:1.0.1" + dset: "npm:^3.1.4" tslib: "npm:^2.4.0" peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - checksum: 657e0758b3cfcbccbaa0c5bf81277c03e02bda32070e71e9f7f728ad692893ef0a0c4bc873b6972edf0b96d0d6397df6e55a8db3e2050bd9c00f6a5bf8881858 + checksum: 17f727eb85415c15c5920ab9ef4648e0d205e1ca8b7d8539ac84f55da04ed60464313792456ebbde30bb883c0abde8df81919fd22f2ed5096b873920e84bef4b languageName: node linkType: hard @@ -2743,7 +2779,7 @@ __metadata: languageName: node linkType: hard -"@mui/private-theming@npm:^5.16.14": +"@mui/private-theming@npm:^5.17.1": version: 5.17.1 resolution: "@mui/private-theming@npm:5.17.1" dependencies: @@ -2862,14 +2898,14 @@ __metadata: linkType: hard "@mui/system@npm:^5.16.12, @mui/system@npm:^5.16.14": - version: 5.16.14 - resolution: "@mui/system@npm:5.16.14" + version: 5.17.1 + resolution: "@mui/system@npm:5.17.1" dependencies: "@babel/runtime": "npm:^7.23.9" - "@mui/private-theming": "npm:^5.16.14" + "@mui/private-theming": "npm:^5.17.1" "@mui/styled-engine": "npm:^5.16.14" - "@mui/types": "npm:^7.2.15" - "@mui/utils": "npm:^5.16.14" + "@mui/types": "npm:~7.2.15" + "@mui/utils": "npm:^5.17.1" clsx: "npm:^2.1.0" csstype: "npm:^3.1.3" prop-types: "npm:^15.8.1" @@ -2885,7 +2921,7 @@ __metadata: optional: true "@types/react": optional: true - checksum: d7ab8dfd9fbecbde4423a0d432e63f45cd8c96bb4e48116f9f9b46cb001c2e32df3a1f09727f8b30c1bc182774cc33e338b1475287a2985dba795ee5486fc4cb + checksum: ab74424e536164b720126ddd31ff0ceea4fb51d72f8d18f9be5621b33f8bbdf7fa8c96f8d1d2c4544ddacbaa84df1a197667f10cbe8915e00df103930e40f56e languageName: node linkType: hard @@ -3815,142 +3851,142 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.40.1" +"@rollup/rollup-android-arm-eabi@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.39.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-android-arm64@npm:4.40.1" +"@rollup/rollup-android-arm64@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-android-arm64@npm:4.39.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.40.1" +"@rollup/rollup-darwin-arm64@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.39.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.40.1" +"@rollup/rollup-darwin-x64@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.39.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.1" +"@rollup/rollup-freebsd-arm64@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.39.0" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-freebsd-x64@npm:4.40.1" +"@rollup/rollup-freebsd-x64@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.39.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.1" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.39.0" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.1" +"@rollup/rollup-linux-arm-musleabihf@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.39.0" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.1" +"@rollup/rollup-linux-arm64-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.39.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.1" +"@rollup/rollup-linux-arm64-musl@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.39.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.40.1" +"@rollup/rollup-linux-loongarch64-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.39.0" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.1" +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.39.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.40.1" +"@rollup/rollup-linux-riscv64-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.39.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.40.1" +"@rollup/rollup-linux-riscv64-musl@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.39.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.40.1" +"@rollup/rollup-linux-s390x-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.39.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.40.1" +"@rollup/rollup-linux-x64-gnu@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.39.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.40.1" +"@rollup/rollup-linux-x64-musl@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.39.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.40.1" +"@rollup/rollup-win32-arm64-msvc@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.39.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.40.1" +"@rollup/rollup-win32-ia32-msvc@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.39.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.40.1": - version: 4.40.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.40.1" +"@rollup/rollup-win32-x64-msvc@npm:4.39.0": + version: 4.39.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.39.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4312,10 +4348,10 @@ __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 +"@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 languageName: node linkType: hard @@ -4326,6 +4362,25 @@ __metadata: languageName: node linkType: hard +"@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.81.2" + checksum: 65aebf52678cbadae81ec8bdf2764781cf975ed0eaf7ee37f77c536139da9f8c9220a1006c0ded50c1abd2d70ff0b76ed5e352dd4c6d3da782df240a2a3d3cbc + languageName: node + linkType: hard + +"@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.81.2" + "@tanstack/query-persist-client-core": "npm:5.81.2" + checksum: cba3d0c0bf032c5a4aac5c49f2ed18f2660a2ae3640741a299a71097682cd15847a58c986827f0a01310d5da407591159cc1ed0b1a8b258100644a98aa554b8a + 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" @@ -4338,6 +4393,18 @@ __metadata: languageName: node linkType: hard +"@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.81.2" + peerDependencies: + "@tanstack/react-query": ^5.81.2 + react: ^18 || ^19 + checksum: cd4176744c6a96295d9c6441e212420b34f83f10f82c59de87aaba0d918de27964fb1d3cd0d91d2cff2e94624fe978a9eb33a348980bf35a624ccb8e5cec0a2f + languageName: node + linkType: hard + "@tanstack/react-query@npm:^5.21.7": version: 5.47.0 resolution: "@tanstack/react-query@npm:5.47.0" @@ -5747,6 +5814,15 @@ __metadata: languageName: node linkType: hard +"@whatwg-node/promise-helpers@npm:^1.0.0": + version: 1.3.1 + resolution: "@whatwg-node/promise-helpers@npm:1.3.1" + dependencies: + tslib: "npm:^2.6.3" + checksum: bb47e71cb588c2bdc45cc78044f8e472b1600d3022f10d59bb70a68bd6a2d2f749f5bbe5858a9b0dccbc93c4c96092acc28e870ace1174d328e14fa8e8db14fa + languageName: node + linkType: hard + "@wry/caches@npm:^1.0.0": version: 1.0.1 resolution: "@wry/caches@npm:1.0.1" @@ -5886,15 +5962,6 @@ __metadata: languageName: node linkType: hard -"acorn-dynamic-import@npm:^4.0.0": - version: 4.0.0 - resolution: "acorn-dynamic-import@npm:4.0.0" - peerDependencies: - acorn: ^6.0.0 - checksum: 5450c917d28f39cabf64495928a711f446cb6a4731d45fcd8f160cc3ceb6fee3e1b4a8cb308b5ba4e9a0e450742f67d7295322033ffaa378a355af6cd2232693 - languageName: node - linkType: hard - "acorn-globals@npm:^7.0.0": version: 7.0.1 resolution: "acorn-globals@npm:7.0.1" @@ -5930,15 +5997,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^6.1.1": - version: 6.4.2 - resolution: "acorn@npm:6.4.2" - bin: - acorn: bin/acorn - checksum: 52a72d5d785fa64a95880f2951021a38954f8f69a4944dfeab6fb1449b0f02293eae109a56d55b58ff31a90a00d16a804658a12db8ef834c20b3d1201fe5ba5b - languageName: node - linkType: hard - "acorn@npm:^8.1.0, acorn@npm:^8.14.0, acorn@npm:^8.7.1, acorn@npm:^8.8.1, acorn@npm:^8.8.2": version: 8.14.1 resolution: "acorn@npm:8.14.1" @@ -7285,13 +7343,20 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:2.6.1, cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.7.0": +"cli-spinners@npm:2.6.1, cli-spinners@npm:^2.7.0": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 languageName: node linkType: hard +"cli-spinners@npm:^2.5.0": + version: 2.7.0 + resolution: "cli-spinners@npm:2.7.0" + checksum: 5c781ace5c8f304ae4d138837f19cf88f03a97de3c3e388f9d1d6434146f06f6ce2a161d6237b3bb86448a05fbcbb20084f3fea96077e42a655b273e39c6f08d + languageName: node + linkType: hard + "cli-table3@npm:~0.6.1": version: 0.6.1 resolution: "cli-table3@npm:0.6.1" @@ -7846,12 +7911,12 @@ __metadata: languageName: node linkType: hard -"cross-inspect@npm:1.0.0": - version: 1.0.0 - resolution: "cross-inspect@npm:1.0.0" +"cross-inspect@npm:1.0.1": + version: 1.0.1 + resolution: "cross-inspect@npm:1.0.1" dependencies: tslib: "npm:^2.4.0" - checksum: 53530865c357c69a5a0543e2f2c61d3d46c9c316a19169372f5094cfb0a7c7e674f2daf2d5253a6731dfd9a8538aa4a4e13c6b4613b6f72b48bb0c41d2015ff4 + checksum: 2493ee47a801b46ede1c42ca6242b8d2059f7319b5baf23887bbaf46a6ea8e536d2e271d0990176c05092f67b32d084ffd8c93e7c1227eff4a06cceadb49af47 languageName: node linkType: hard @@ -8686,7 +8751,7 @@ __metadata: languageName: node linkType: hard -"dset@npm:^3.1.2": +"dset@npm:^3.1.4": version: 3.1.4 resolution: "dset@npm:3.1.4" checksum: b67bbd28dd8a539e90c15ffb61100eb64ef995c5270a124d4f99bbb53f4d82f55a051b731ba81f3215dd9dce2b4c8d69927dc20b3be1c5fc88bab159467aa438 @@ -9741,7 +9806,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.3, express@npm:^4.20.0": +"express@npm:^4.20.0, express@npm:^4.21.2": version: 4.21.2 resolution: "express@npm:4.21.2" dependencies: @@ -10889,12 +10954,12 @@ __metadata: languageName: node linkType: hard -"graphql-http@npm:^1.22.1": - version: 1.22.1 - resolution: "graphql-http@npm:1.22.1" +"graphql-http@npm:^1.22.4": + version: 1.22.4 + resolution: "graphql-http@npm:1.22.4" peerDependencies: graphql: ">=0.11 <=16" - checksum: 969b65dbebbdb6616632e9278d050cc71ba2ae4ff8038b4d83be26d46fc83a4ae54545a0ead052cab0ddfae92d2ddff6aceaef877e74a33f4c7d7e3acc1fab89 + checksum: 039e55545fda36ba9bae566ae5d528c4dd9d5972ce3799413741f57309107849930343449b024874041cdcbb7242c0562a19c48d83fccabbf0c7d9f0d3d5a43a languageName: node linkType: hard @@ -10925,10 +10990,10 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^16.8.1": - version: 16.8.1 - resolution: "graphql@npm:16.8.1" - checksum: 129c318156b466f440914de80dbf7bc67d17f776f2a088a40cb0da611d19a97c224b1c6d2b13cbcbc6e5776e45ed7468b8432f9c3536724e079b44f1a3d57a8a +"graphql@npm:^16.10.0": + version: 16.11.0 + resolution: "graphql@npm:16.11.0" + checksum: 124da7860a2292e9acf2fed0c71fc0f6a9b9ca865d390d112bdd563c1f474357141501c12891f4164fe984315764736ad67f705219c62f7580681d431a85db88 languageName: node linkType: hard @@ -11422,7 +11487,7 @@ __metadata: languageName: node linkType: hard -"inflection@npm:^3.0.0": +"inflection@npm:^3.0.0, inflection@npm:^3.0.2": version: 3.0.2 resolution: "inflection@npm:3.0.2" checksum: ac6b635f029b27834313ce30188d74607fe9751c729bf91698675b2fd82489e0195e884d8a9455676064a74b2db77b407d35b56ada0978d0e8194e72202bf7af @@ -12918,24 +12983,22 @@ __metadata: linkType: hard "json-graphql-server@npm:^3.0.1": - version: 3.1.2 - resolution: "json-graphql-server@npm:3.1.2" + version: 3.2.1 + resolution: "json-graphql-server@npm:3.2.1" dependencies: - "@apollo/client": "npm:^3.9.11" - "@graphql-tools/schema": "npm:^10.0.3" + "@apollo/client": "npm:^3.12.11" + "@graphql-tools/schema": "npm:^10.0.18" cors: "npm:^2.8.5" - express: "npm:^4.17.3" - graphql: "npm:^16.8.1" - graphql-http: "npm:^1.22.1" - graphql-tag: "npm:^2.12.6" + express: "npm:^4.21.2" + graphql: "npm:^16.10.0" + graphql-http: "npm:^1.22.4" graphql-type-json: "npm:^0.3.2" - inflection: "npm:^3.0.0" + inflection: "npm:^3.0.2" lodash.merge: "npm:^4.6.2" - reify: "npm:^0.20.12" xhr-mock: "npm:^2.5.1" bin: json-graphql-server: bin/json-graphql-server.cjs - checksum: 384a34e0a59dc548492b8401087b7cd339b6e0ea52686dc64e9253e833da921d55e4258159cbe0e4ff36a28aef3ffe8aadd02af64a28025c950be544ea53daf0 + checksum: 6dc48fc8ba44f871d1f2511bde2213a5df96f89f069ae94962e1856c3c05d5d80b845d8ed9ef293a1ac1c61760377cd415044f1f4dd9e198f2312404b6068fe9 languageName: node linkType: hard @@ -13616,15 +13679,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.25.3": - version: 0.25.7 - resolution: "magic-string@npm:0.25.7" - dependencies: - sourcemap-codec: "npm:^1.4.4" - checksum: d5da35f01d5437d7d6c030fe8185285a78b97144d07944d62187bd985ee2f6dcc8c9a538ded6a3afe186f5d6f2e705b45f9f307b19020aff530447bd32f24375 - languageName: node - linkType: hard - "magic-string@npm:^0.30.5": version: 0.30.10 resolution: "magic-string@npm:0.30.10" @@ -16743,14 +16797,14 @@ __metadata: linkType: hard "react-router-dom@npm:^7.1.1": - version: 7.6.1 - resolution: "react-router-dom@npm:7.6.1" + version: 7.5.3 + resolution: "react-router-dom@npm:7.5.3" dependencies: - react-router: "npm:7.6.1" + react-router: "npm:7.5.3" peerDependencies: react: ">=18" react-dom: ">=18" - checksum: 9d448d82d73c18475c8e3e2f93cf9dd16ca0488379e295f5a949aa849aaf770eb6257d48f2aef2c62ac1635e8ccddd0fa53173c2479525305eb656936f330812 + checksum: 56c03d8c31c100db54029df82f7e2d350ec75d301b2ffa9512fbc7659faf492ef6d777115629da89c92d0826f3b8a98271dd8426fd3afd3aaf9ed92763f7deaa languageName: node linkType: hard @@ -16765,7 +16819,24 @@ __metadata: languageName: node linkType: hard -"react-router@npm:7.6.1, react-router@npm:^7.1.1": +"react-router@npm:7.5.3": + version: 7.5.3 + resolution: "react-router@npm:7.5.3" + dependencies: + cookie: "npm:^1.0.1" + set-cookie-parser: "npm:^2.6.0" + turbo-stream: "npm:2.4.0" + peerDependencies: + react: ">=18" + react-dom: ">=18" + peerDependenciesMeta: + react-dom: + optional: true + checksum: 1f98ab5974cbf6696944a3cbe3d5708add6cdb9c765e0952459eb912d388fe361914a94557546dcd45164413fd9bc2fde97302c8daf3951156644232a9e3ce16 + languageName: node + linkType: hard + +"react-router@npm:^7.1.1": version: 7.6.1 resolution: "react-router@npm:7.6.1" dependencies: @@ -17091,18 +17162,6 @@ __metadata: languageName: node linkType: hard -"reify@npm:^0.20.12": - version: 0.20.12 - resolution: "reify@npm:0.20.12" - dependencies: - acorn: "npm:^6.1.1" - acorn-dynamic-import: "npm:^4.0.0" - magic-string: "npm:^0.25.3" - semver: "npm:^5.4.1" - checksum: e8bbe083a06d4d99e649160e1a4ef7f0e40a87575e2af4b070bbae6f94399e7779659424db777d3b30e7aabff086e3e2fb11b50adf16e74fe77aed3de9503483 - languageName: node - linkType: hard - "relateurl@npm:^0.2.7": version: 0.2.7 resolution: "relateurl@npm:0.2.7" @@ -17370,30 +17429,30 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.34.9": - version: 4.40.1 - resolution: "rollup@npm:4.40.1" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.40.1" - "@rollup/rollup-android-arm64": "npm:4.40.1" - "@rollup/rollup-darwin-arm64": "npm:4.40.1" - "@rollup/rollup-darwin-x64": "npm:4.40.1" - "@rollup/rollup-freebsd-arm64": "npm:4.40.1" - "@rollup/rollup-freebsd-x64": "npm:4.40.1" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.40.1" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.40.1" - "@rollup/rollup-linux-arm64-gnu": "npm:4.40.1" - "@rollup/rollup-linux-arm64-musl": "npm:4.40.1" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.40.1" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.40.1" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.40.1" - "@rollup/rollup-linux-riscv64-musl": "npm:4.40.1" - "@rollup/rollup-linux-s390x-gnu": "npm:4.40.1" - "@rollup/rollup-linux-x64-gnu": "npm:4.40.1" - "@rollup/rollup-linux-x64-musl": "npm:4.40.1" - "@rollup/rollup-win32-arm64-msvc": "npm:4.40.1" - "@rollup/rollup-win32-ia32-msvc": "npm:4.40.1" - "@rollup/rollup-win32-x64-msvc": "npm:4.40.1" +"rollup@npm:^4.30.1": + version: 4.39.0 + resolution: "rollup@npm:4.39.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.39.0" + "@rollup/rollup-android-arm64": "npm:4.39.0" + "@rollup/rollup-darwin-arm64": "npm:4.39.0" + "@rollup/rollup-darwin-x64": "npm:4.39.0" + "@rollup/rollup-freebsd-arm64": "npm:4.39.0" + "@rollup/rollup-freebsd-x64": "npm:4.39.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.39.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.39.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.39.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.39.0" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.39.0" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.39.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.39.0" + "@rollup/rollup-linux-riscv64-musl": "npm:4.39.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.39.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.39.0" + "@rollup/rollup-linux-x64-musl": "npm:4.39.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.39.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.39.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.39.0" "@types/estree": "npm:1.0.7" fsevents: "npm:~2.3.2" dependenciesMeta: @@ -17441,7 +17500,7 @@ __metadata: optional: true bin: rollup: dist/bin/rollup - checksum: 11c44b5ef9b3fd521c5501b3f1c36af4ca07821aeff41d41f45336eee324d8f5b46c1a92189f5c8cd146bc21ac10418d57cb4571637ea09aced1ae831a2a4ae0 + checksum: 2dc0c23ca04bd00295035b405c977261559aed8acc9902ee9ff44e4a6b54734fcb64999c32143c43804dcb543da7983032831b893a902633b006c21848a093ce languageName: node linkType: hard @@ -17587,7 +17646,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.6.0": +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" bin: @@ -17849,8 +17908,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.3.4" jsonexport: "npm:^3.2.0" little-state-machine: "npm:^4.8.1" @@ -18031,13 +18092,6 @@ __metadata: languageName: node linkType: hard -"sourcemap-codec@npm:^1.4.4": - version: 1.4.8 - resolution: "sourcemap-codec@npm:1.4.8" - checksum: f099279fdaae070ff156df7414bbe39aad69cdd615454947ed3e19136bfdfcb4544952685ee73f56e17038f4578091e12b17b283ed8ac013882916594d95b9e6 - languageName: node - linkType: hard - "spdx-correct@npm:^3.0.0": version: 3.1.1 resolution: "spdx-correct@npm:3.1.1" @@ -18670,7 +18724,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13": +"tinyglobby@npm:^0.2.12": version: 0.2.13 resolution: "tinyglobby@npm:0.2.13" dependencies: @@ -18900,7 +18954,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.3, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -18943,6 +18997,13 @@ __metadata: languageName: node linkType: hard +"turbo-stream@npm:2.4.0": + version: 2.4.0 + resolution: "turbo-stream@npm:2.4.0" + checksum: e68b2569f1f16e6e9633d090c6024b2ae9f0e97bfeacb572451ca3732e120ebbb546f3bc4afc717c46cb57b5aea6104e04ef497f9912eef6a7641e809518e98a + languageName: node + linkType: hard + "tutorial@workspace:examples/tutorial": version: 0.0.0-use.local resolution: "tutorial@workspace:examples/tutorial" @@ -19523,13 +19584,6 @@ __metadata: languageName: node linkType: hard -"value-or-promise@npm:^1.0.12": - version: 1.0.12 - resolution: "value-or-promise@npm:1.0.12" - checksum: b75657b74e4d17552bd88e0c2857020fbab34a4d091dc058db18c470e7da0336067e72c130b3358e3321ac0a6ff11c0b92b67a382318a3705ad5d57de7ff3262 - languageName: node - linkType: hard - "vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -19549,16 +19603,13 @@ __metadata: linkType: hard "vite@npm:^6.2.6": - version: 6.3.4 - resolution: "vite@npm:6.3.4" + version: 6.2.6 + resolution: "vite@npm:6.2.6" dependencies: esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.4" fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.2" postcss: "npm:^8.5.3" - rollup: "npm:^4.34.9" - tinyglobby: "npm:^0.2.13" + rollup: "npm:^4.30.1" peerDependencies: "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: ">=1.21.0" @@ -19599,7 +19650,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: f1534a3f42d14b30e11c58e5e451903d965d5f5ba18d8c81f9df208589e3d2c65535abaa3268d3963573174b8e056ea7bc445f567622c65fcdf98eb4acc1bf4e + checksum: 68a2ed3e61bdd654c59b817b4f3203065241c66d1739faa707499130f3007bc3a666c7a8320a4198e275e62b5e4d34d9b78a6533f69e321d366e76f5093b2071 languageName: node linkType: hard