diff --git a/docs/Create.md b/docs/Create.md index 9a2b455b447..d8e8a94cb6c 100644 --- a/docs/Create.md +++ b/docs/Create.md @@ -60,6 +60,7 @@ You can customize the `` component using the following props: * `className`: passed to the root component * [`component`](#component): override the root component * [`disableAuthentication`](#disableauthentication): disable the authentication check +* [`mutationMode`](#mutationmode): switch to optimistic or undoable mutations (pessimistic by default) * [`mutationOptions`](#mutationoptions): options for the `dataProvider.create()` call * [`record`](#record): initialize the form with a record * [`redirect`](#redirect): change the redirect location after successful creation @@ -154,6 +155,34 @@ const PostCreate = () => ( ); ``` +## `mutationMode` + +The `` view exposes a Save button, which perform a "mutation" (i.e. it creates the data). React-admin offers three modes for mutations. The mode determines when the side effects (redirection, notifications, etc.) are executed: + +- `pessimistic` (default): The mutation is passed to the dataProvider first. When the dataProvider returns successfully, the mutation is applied locally, and the side effects are executed. +- `optimistic`: The mutation is applied locally and the side effects are executed immediately. Then the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. +- `undoable`: The mutation is applied locally and the side effects are executed immediately. Then a notification is shown with an undo button. If the user clicks on undo, the mutation is never sent to the dataProvider, and the page is refreshed. Otherwise, after a 5 seconds delay, the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. + +By default, pages using `` use the `pessimistic` mutation mode as the new record identifier is often generated on the backend. However, should you decide to generate this identifier client side, you can change the `mutationMode` to either `optimistic` or `undoable`: + +```jsx +const PostCreate = () => ( + ({ id: generateId(), ...data })}> + // ... + +); +``` + +And to make the record creation undoable: + +```jsx +const PostCreate = () => ( + ({ id: generateId(), ...data })}> + // ... + +); +``` + ## `mutationOptions` You can customize the options you pass to react-query's `useMutation` hook, e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.create()` call. diff --git a/docs/CreateBase.md b/docs/CreateBase.md index c493e3035dd..4175a7172f2 100644 --- a/docs/CreateBase.md +++ b/docs/CreateBase.md @@ -46,6 +46,7 @@ You can customize the `` component using the following props, docume * `children`: the components that renders the form * [`disableAuthentication`](./Create.md#disableauthentication): disable the authentication check +* [`mutationMode`](./Create.md#mutationmode): Switch to optimistic or undoable mutations (pessimistic by default) * [`mutationOptions`](./Create.md#mutationoptions): options for the `dataProvider.create()` call * [`record`](./Create.md#record): initialize the form with a record * [`redirect`](./Create.md#redirect): change the redirect location after successful creation diff --git a/docs/useCreate.md b/docs/useCreate.md index 2987049f0f1..366117839da 100644 --- a/docs/useCreate.md +++ b/docs/useCreate.md @@ -9,7 +9,7 @@ This hook allows to call `dataProvider.create()` when the callback is executed. ## Syntax -```jsx +```tsx const [create, { data, isPending, error }] = useCreate( resource, { data, meta }, @@ -19,7 +19,7 @@ const [create, { data, isPending, error }] = useCreate( The `create()` method can be called with the same parameters as the hook: -```jsx +```tsx create( resource, { data }, @@ -31,7 +31,7 @@ So, should you pass the parameters when calling the hook, or when executing the ## Usage -```jsx +```tsx // set params when calling the hook import { useCreate, useRecordContext } from 'react-admin'; @@ -61,6 +61,267 @@ const LikeButton = () => { }; ``` +## Params + +The second argument of the `useCreate` hook is an object with the following properties: + +- `data`: the new data for the record, +- `meta`: an object to pass additional information to the dataProvider (optional). + +```tsx +const LikeButton = () => { + const record = useRecordContext(); + const like = { postId: record.id }; + const [create, { isPending, error }] = useCreate('likes', { data: like }); + const handleClick = () => { + create() + } + if (error) { return

ERROR

; } + return ; +};``` + +`data` the record to create. + +`meta` is helpful for passing additional information to the dataProvider. For instance, you can pass the current user to let a server-side audit system know who made the creation. + +## Options + +`useCreate`'s third parameter is an `options` object with the following properties: + +- `mutationMode`, +- `onError`, +- `onSettled`, +- `onSuccess`, +- `returnPromise`. + +```tsx +const notify = useNotify(); +const redirect = useRedirect(); +const like = { postId: record.id }; +const [create, { isPending, error }] = useCreate( + 'likes', + { data: like }, + { + mutationMode: 'optimistic', + onSuccess: () => { + notify('Like created'); + redirect('/reviews'); + }, + onError: (error) => { + notify(`Like creation error: ${error.message}`, { type: 'error' }); + }, + }); + +``` + +Additional options are passed to [React Query](https://tanstack.com/query/v5/)'s [`useMutation`](https://tanstack.com/query/v5/docs/react/reference/useMutation) hook. This includes: + +- `gcTime`, +- `networkMode`, +- `onMutate`, +- `retry`, +- `retryDelay`, +- `mutationKey`, +- `throwOnError`. + +Check [the useMutation documentation](https://tanstack.com/query/v5/docs/react/reference/useMutation) for a detailed description of all options. + +**Tip**: In react-admin components that use `useCreate`, you can override the mutation options using the `mutationOptions` prop. This is very common when using mutation hooks like `useCreate`, e.g., to display a notification or redirect to another page. + +For instance, here is a button using `` to notify the user of success using the bottom notification banner: + +{% raw %} +```tsx +import * as React from 'react'; +import { useNotify, useRedirect, Create, SimpleForm } from 'react-admin'; + +const PostCreate = () => { + const notify = useNotify(); + const redirect = useRedirect(); + + const onSuccess = (data) => { + notify(`Changes saved`); + redirect(`/posts/${data.id}`); + }; + + return ( + + + ... + + + ); +} +``` +{% endraw %} + +## Return Value + +The `useCreate` hook returns an array with two values: + +- the `create` callback, and +- a mutation state object with the following properties: + - `data`, + - `error`, + - `isError`, + - `isIdle`, + - `isPending`, + - `isPaused`, + - `isSuccess`, + - `failureCount`, + - `failureReason`, + - `mutate`, + - `mutateAsync`, + - `reset`, + - `status`, + - `submittedAt`, + - `variables`. + +The `create` callback can be called with a `resource` and a `param` argument, or, if these arguments were defined when calling `useCreate`, with no argument at all: + +```jsx +// Option 1: define the resource and params when calling the callback +const [create, { isPending }] = useCreate(); +const handleClick = () => { + create(resource, params, options); +}; + +// Option 2: define the resource and params when calling the hook +const [create, { isPending }] = useCreate(resource, params, options); +const handleClick = () => { + create(); +}; +``` + +For a detailed description of the mutation state, check React-query's [`useMutation` documentation](https://tanstack.com/query/v5/docs/react/reference/useMutation). + +Since `useCreate` is mainly used in event handlers, success and error side effects are usually handled in the `onSuccess` and `onError` callbacks. In most cases, the mutation state is just used to disable the save button while the mutation is pending. + +## `mutationMode` + +The `mutationMode` option lets you switch between three rendering modes, which change how the success side effects are triggered: + +- `pessimistic` (the default) +- `optimistic`, and +- `undoable` + +**Note**: For `optimistic` and `undoable` modes, the record `id` must be generated client side. Those two modes are useful when building local first applications. + +Here is an example of using the `optimistic` mode: + +```jsx +// In optimistic mode, ids must be generated client side +const id = uuid.v4(); +const [create, { data, isPending, error }] = useCreate( + 'comments', + { data: { id, message: 'Lorem ipsum' } }, + { + mutationMode: 'optimistic', + onSuccess: () => { /* ... */}, + onError: () => { /* ... */}, + } +); +``` + +In `pessimistic` mode, the `onSuccess` side effect executes *after* the dataProvider responds. + +In `optimistic` mode, the `onSuccess` side effect executes just before the `dataProvider.create()` is called, without waiting for the response. + +In `undoable` mode, the `onSuccess` side effect fires immediately. The actual call to the dataProvider is delayed until the create notification hides. If the user clicks the undo button, the `dataProvider.create()` call is never made. + +See [Optimistic Rendering and Undo](./Actions.md#optimistic-rendering-and-undo) for more details. + +**Tip**: If you need a side effect to be triggered after the dataProvider response in `optimistic` and `undoable` modes, use the `onSettled` callback. + +## `onError` + +The `onError` callback is called when the mutation fails. It's the perfect place to display an error message to the user. + +```jsx +const notify = useNotify(); +const [create, { data, isPending, error }] = useCreate( + 'comments', + { id: record.id, data: { isApproved: true } }, + { + onError: () => { + notify('Error: comment not approved', { type: 'error' }); + }, + } +); +``` + +**Note**: If you use the `retry` option, the `onError` callback is called only after the last retry has failed. + +## `onSettled` + +The `onSettled` callback is called at the end of the mutation, whether it succeeds or fails. It will receive either the `data` or the `error`. + +```jsx +const notify = useNotify(); +const [create, { data, isPending, error }] = useCreate( + 'comments', + { id: record.id, data: { isApproved: true } }, + { + onSettled: (data, error) => { + // ... + }, + } +); +``` + +**Tip**: The `onSettled` callback is perfect for calling a success side effect after the dataProvider response in `optimistic` and `undoable` modes. + +## `onSuccess` + +The `onSuccess` callback is called when the mutation succeeds. It's the perfect place to display a notification or to redirect the user to another page. + +```jsx +const notify = useNotify(); +const redirect = useRedirect(); +const [create, { data, isPending, error }] = useCreate( + 'comments', + { id: record.id, data: { isApproved: true } }, + { + onSuccess: () => { + notify('Comment approved'); + redirect('/comments'); + }, + } +); +``` + +In `pessimistic` mutation mode, `onSuccess` executes *after* the `dataProvider.create()` responds. React-admin passes the result of the `dataProvider.create()` call as the first argument to the `onSuccess` callback. + +In `optimistic` mutation mode, `onSuccess` executes *before* the `dataProvider.create()` is called, without waiting for the response. The callback receives no argument. + +In `undoable` mutation mode, `onSuccess` executes *before* the `dataProvider.create()` is called. The actual call to the dataProvider is delayed until the create notification hides. If the user clicks the undo button, the `dataProvider.create()` call is never made. The callback receives no argument. + +## `returnPromise` + +By default, the `create` callback that `useCreate` returns is synchronous and returns nothing. To execute a side effect after the mutation has succeeded, you can use the `onSuccess` callback. + +If this is not enough, you can use the `returnPromise` option so that the `create` callback returns a promise that resolves when the mutation has succeeded and rejects when the mutation has failed. + +This can be useful if the server changes the record, and you need the newly created data to create/update another record. + +```jsx +const [create] = useCreate( + 'posts', + { id: record.id, data: { isPublished: true } }, + { returnPromise: true } +); +const [create] = useCreate('auditLogs'); + +const createPost = async () => { + try { + const post = await create(); + create('auditLogs', { data: { action: 'create', recordId: post.id, date: post.createdAt } }); + } catch (error) { + // handle error + } +}; +``` + ## TypeScript The `useCreate` hook accepts a generic parameter for the record type and another for the error type: diff --git a/docs/useCreateController.md b/docs/useCreateController.md index 9e3358be9f7..29cc484abe5 100644 --- a/docs/useCreateController.md +++ b/docs/useCreateController.md @@ -50,6 +50,7 @@ export const BookCreate = () => { `useCreateController` accepts an object with the following keys, all optional: * [`disableAuthentication`](./Create.md#disableauthentication): Disable the authentication check +* [`mutationMode`](./Create.md#mutationmode): Switch to optimistic or undoable mutations (pessimistic by default) * [`mutationOptions`](./Create.md#mutationoptions): Options for the `dataProvider.create()` call * [`record`](./Create.md#record): Use the provided record as base instead of fetching it * [`redirect`](./Create.md#redirect): Change the redirect location after successful creation @@ -65,6 +66,7 @@ These fields are documented in [the `` component](./Create.md) documenta ```jsx const { defaultTitle, // Translated title based on the resource, e.g. 'Create New Post' + mutationMode, // Mutation mode argument passed as parameter, or 'pessimistic' if not defined record, // Default values of the creation form redirect, // Default redirect route. Defaults to 'list' resource, // Resource name, deduced from the location. e.g. 'posts' diff --git a/packages/ra-core/src/controller/create/CreateBase.spec.tsx b/packages/ra-core/src/controller/create/CreateBase.spec.tsx index 80b35dc8e32..6631e72823c 100644 --- a/packages/ra-core/src/controller/create/CreateBase.spec.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.spec.tsx @@ -53,7 +53,7 @@ describe('CreateBase', () => { test: 'test', }, { data: { test: 'test' }, resource: 'posts' }, - undefined + { snapshot: [] } ); }); }); @@ -85,7 +85,7 @@ describe('CreateBase', () => { test: 'test', }, { data: { test: 'test' }, resource: 'posts' }, - undefined + { snapshot: [] } ); }); expect(onSuccess).not.toHaveBeenCalled(); @@ -112,7 +112,7 @@ describe('CreateBase', () => { expect(onError).toHaveBeenCalledWith( { message: 'test' }, { data: { test: 'test' }, resource: 'posts' }, - undefined + { snapshot: [] } ); }); }); @@ -139,7 +139,7 @@ describe('CreateBase', () => { expect(onErrorOverride).toHaveBeenCalledWith( { message: 'test' }, { data: { test: 'test' }, resource: 'posts' }, - undefined + { snapshot: [] } ); }); expect(onError).not.toHaveBeenCalled(); diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index b873e426384..2ed0ff6a88d 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -105,6 +105,7 @@ describe('useCreateController', () => { smart_count: 1, _: 'ra.notification.created', }, + undoable: false, }, }, ]); diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index be3b2f7c633..a6d956cdb26 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -15,7 +15,7 @@ import { useMutationMiddlewares, } from '../saveContext'; import { useTranslate } from '../../i18n'; -import { Identifier, RaRecord, TransformData } from '../../types'; +import { Identifier, MutationMode, RaRecord, TransformData } from '../../types'; import { useResourceContext, useResourceDefinition, @@ -55,6 +55,7 @@ export const useCreateController = < record, redirect: redirectTo, transform, + mutationMode = 'pessimistic', mutationOptions = {}, } = props; @@ -96,7 +97,6 @@ export const useCreateController = < if (onSuccess) { return onSuccess(data, variables, context); } - notify(`resources.${resource}.notifications.created`, { type: 'info', messageArgs: { @@ -105,6 +105,7 @@ export const useCreateController = < smart_count: 1, }), }, + undoable: mutationMode === 'undoable', }); redirect(finalRedirectTo, resource, data.id, data); }, @@ -117,7 +118,7 @@ export const useCreateController = < const validationErrors = (error as HttpError)?.body?.errors; const hasValidationErrors = !!validationErrors && Object.keys(validationErrors).length > 0; - if (!hasValidationErrors) { + if (!hasValidationErrors || mutationMode !== 'pessimistic') { notify( typeof error === 'string' ? error @@ -142,7 +143,8 @@ export const useCreateController = < } }, ...otherMutationOptions, - returnPromise: true, + mutationMode, + returnPromise: mutationMode === 'pessimistic', getMutateWithMiddlewares, }); @@ -150,9 +152,10 @@ export const useCreateController = < ( data: Partial, { + onSuccess: onSuccessFromSave, + onError: onErrorFromSave, transform: transformFromSave, meta: metaFromSave, - ...callTimeOptions } = {} as SaveHandlerCallbacks ) => Promise.resolve( @@ -166,7 +169,10 @@ export const useCreateController = < await create( resource, { data, meta: metaFromSave ?? meta }, - callTimeOptions + { + onError: onErrorFromSave, + onSuccess: onSuccessFromSave, + } ); } catch (error) { if ( @@ -192,6 +198,7 @@ export const useCreateController = < isFetching: false, isLoading: false, isPending: disableAuthentication ? false : isPendingCanAccess, + mutationMode, saving, defaultTitle, save, @@ -214,6 +221,7 @@ export interface CreateControllerProps< record?: Partial; redirect?: RedirectionSideEffect; resource?: string; + mutationMode?: MutationMode; mutationOptions?: UseMutationOptions< ResultRecordType, MutationOptionsError, diff --git a/packages/ra-core/src/core/CoreAdmin.tsx b/packages/ra-core/src/core/CoreAdmin.tsx index b638da17d17..e8b7be7245d 100644 --- a/packages/ra-core/src/core/CoreAdmin.tsx +++ b/packages/ra-core/src/core/CoreAdmin.tsx @@ -99,6 +99,7 @@ export const CoreAdmin = (props: CoreAdminProps) => { loading, loginPage, queryClient, + QueryClientProvider, ready, requireAuth, store, @@ -111,6 +112,7 @@ export const CoreAdmin = (props: CoreAdminProps) => { dataProvider={dataProvider} i18nProvider={i18nProvider} queryClient={queryClient} + QueryClientProvider={QueryClientProvider} store={store} > { store = defaultStore, children, queryClient, + QueryClientProvider = DefaultQueryClientProvider, } = props; if (!dataProvider) { diff --git a/packages/ra-core/src/dataProvider/useCreate.optimistic.stories.tsx b/packages/ra-core/src/dataProvider/useCreate.optimistic.stories.tsx new file mode 100644 index 00000000000..d711e3ca81f --- /dev/null +++ b/packages/ra-core/src/dataProvider/useCreate.optimistic.stories.tsx @@ -0,0 +1,405 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useCreate } from './useCreate'; +import { useGetOne } from './useGetOne'; + +export default { title: 'ra-core/dataProvider/useCreate/optimistic' }; + +export const SuccessCase = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + posts.push(params.data); + resolve({ data: params.data }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, error, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate(); + const handleClick = () => { + create( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + mutationMode: 'optimistic', + onSuccess: () => setSuccess('success'), + } + ); + }; + return ( + <> + {error ? ( +

{error.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const ErrorCase = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const ErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, error: getOneError, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate(); + const handleClick = () => { + create( + 'posts', + { + data: { + id: 2, + title: 'Hello World', + }, + }, + { + mutationMode: 'optimistic', + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + }; + return ( + <> + {getOneError ? ( +

{getOneError.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ +   + +
+ {success &&
{success}
} + {error &&
{error.message}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresSuccess = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + posts.push(params.data); + resolve({ data: params.data }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresSuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, error, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate( + 'posts', + { + data: { + id: 2, + title: 'Hello World', + }, + }, + { + mutationMode: 'optimistic', + // @ts-ignore + getMutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { + ...params.data, + title: `${params.data.title} from middleware`, + }, + }); + }, + } + ); + const handleClick = () => { + create( + 'posts', + { + data: { + id: 2, + title: 'Hello World', + }, + }, + { + onSuccess: () => setSuccess('success'), + } + ); + }; + return ( + <> + {error ? ( +

{error.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, error: getOneError, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate( + 'posts', + { + data: { + id: 2, + title: 'Hello World', + }, + }, + { + mutationMode: 'optimistic', + // @ts-ignore + mutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { + ...params.data, + title: `${params.data.title} from middleware`, + }, + }); + }, + } + ); + const handleClick = () => { + setError(undefined); + create( + 'posts', + { + data: { + id: 2, + title: 'Hello World', + }, + }, + { + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + }; + return ( + <> + {getOneError ? ( +

{getOneError.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ +   + +
+ {error &&
{error.message}
} + {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useCreate.stories.tsx b/packages/ra-core/src/dataProvider/useCreate.pessimistic.stories.tsx similarity index 99% rename from packages/ra-core/src/dataProvider/useCreate.stories.tsx rename to packages/ra-core/src/dataProvider/useCreate.pessimistic.stories.tsx index 41b0de4fb32..2d19d4b4e78 100644 --- a/packages/ra-core/src/dataProvider/useCreate.stories.tsx +++ b/packages/ra-core/src/dataProvider/useCreate.pessimistic.stories.tsx @@ -6,7 +6,7 @@ import { CoreAdminContext } from '../core'; import { useCreate } from './useCreate'; import { useGetOne } from './useGetOne'; -export default { title: 'ra-core/dataProvider/useCreate' }; +export default { title: 'ra-core/dataProvider/useCreate/pessimistic' }; export const SuccessCase = ({ timeout = 1000 }) => { const posts: { id: number; title: string; author: string }[] = []; diff --git a/packages/ra-core/src/dataProvider/useCreate.spec.tsx b/packages/ra-core/src/dataProvider/useCreate.spec.tsx index 0efcaf5ad7a..45ac3b65657 100644 --- a/packages/ra-core/src/dataProvider/useCreate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useCreate.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, act } from '@testing-library/react'; import expect from 'expect'; import { RaRecord } from '../types'; @@ -8,9 +8,23 @@ import { useCreate } from './useCreate'; import { useGetList } from './useGetList'; import { CoreAdminContext } from '../core'; import { - WithMiddlewaresError, - WithMiddlewaresSuccess, -} from './useCreate.stories'; + ErrorCase as ErrorCasePessimistic, + SuccessCase as SuccessCasePessimistic, + WithMiddlewaresSuccess as WithMiddlewaresSuccessPessimistic, + WithMiddlewaresError as WithMiddlewaresErrorPessimistic, +} from './useCreate.pessimistic.stories'; +import { + ErrorCase as ErrorCaseOptimistic, + SuccessCase as SuccessCaseOptimistic, + WithMiddlewaresSuccess as WithMiddlewaresSuccessOptimistic, + WithMiddlewaresError as WithMiddlewaresErrorOptimistic, +} from './useCreate.optimistic.stories'; +import { + ErrorCase as ErrorCaseUndoable, + SuccessCase as SuccessCaseUndoable, + WithMiddlewaresSuccess as WithMiddlewaresSuccessUndoable, + WithMiddlewaresError as WithMiddlewaresErrorUndoable, +} from './useCreate.undoable.stories'; describe('useCreate', () => { it('returns a callback that can be used with create arguments', async () => { @@ -186,9 +200,148 @@ describe('useCreate', () => { }); }); + describe('mutationMode', () => { + it('when pessimistic, displays result and success side effects when dataProvider promise resolves', async () => { + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect(screen.queryByText('Hello World')).toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + it('when pessimistic, displays error and error side effects when dataProvider promise rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect(screen.queryByText('something went wrong')).toBeNull(); + expect(screen.queryByText('Hello World')).toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('something went wrong') + ).not.toBeNull(); + expect(screen.queryByText('not found')).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + it('when optimistic, displays result and success side effects right away', async () => { + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + it('when optimistic, displays error and error side effects when dataProvider promise rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('something went wrong') + ).not.toBeNull(); + expect(screen.queryByText('Hello World')).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + await screen.findByText('not found'); + }); + it('when undoable, displays result and success side effects right away and fetched on confirm', async () => { + render(); + act(() => { + screen.getByText('Create post').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Confirm').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor( + () => { + expect(screen.queryByText('mutating')).toBeNull(); + }, + { timeout: 4000 } + ); + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + }); + it('when undoable, displays result and success side effects right away and reverts on cancel', async () => { + render(); + await screen.findByText('not found'); + act(() => { + screen.getByText('Create post').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Cancel').click(); + }); + await waitFor(() => { + expect(screen.queryByText('Hello World')).toBeNull(); + }); + expect(screen.queryByText('mutating')).toBeNull(); + await screen.findByText('not found'); + }); + it('when undoable, displays result and success side effects right away and reverts on error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('not found', undefined, { timeout: 5000 }); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + screen.getByText('Confirm').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await screen.findByText('not found', undefined, { timeout: 4000 }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect(screen.queryByText('Hello World')).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + }); + describe('middlewares', () => { - it('it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { - render(); + it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { + render(); screen.getByText('Create post').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -206,9 +359,9 @@ describe('useCreate', () => { }); }); - it('it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { + it('when pessimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); - render(); + render(); screen.getByText('Create post').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -229,5 +382,123 @@ describe('useCreate', () => { expect(screen.queryByText('mutating')).toBeNull(); }); }); + + it('when optimistic, it accepts middlewares and displays result and success side effects right away', async () => { + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + it('when optimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('something went wrong') + ).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + await screen.findByText('not found'); + }); + + it('when undoable, it accepts middlewares and displays result and success side effects right away and fetched on confirm', async () => { + render(); + act(() => { + screen.getByText('Create post').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Confirm').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor( + () => { + expect(screen.queryByText('mutating')).toBeNull(); + }, + { timeout: 4000 } + ); + expect(screen.queryByText('success')).not.toBeNull(); + await screen.findByText('Hello World from middleware'); + }); + it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on cancel', async () => { + render(); + await screen.findByText('not found'); + act(() => { + screen.getByText('Create post').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Cancel').click(); + }); + await waitFor(() => { + expect(screen.queryByText('Hello World')).toBeNull(); + }); + expect(screen.queryByText('mutating')).toBeNull(); + await screen.findByText('not found'); + }); + it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('not found', undefined, { timeout: 5000 }); + screen.getByText('Create post').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + screen.getByText('Confirm').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await screen.findByText('Hello World', undefined, { + timeout: 4000, + }); + await waitFor( + () => { + expect(screen.queryByText('success')).toBeNull(); + }, + { timeout: 5000 } + ); + + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }, 6000); }); }); diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index b84995cfe89..ff42f8c1446 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -5,11 +5,23 @@ import { UseMutationResult, useQueryClient, MutateOptions, + QueryKey, + UseInfiniteQueryResult, + InfiniteData, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { RaRecord, CreateParams, Identifier, DataProvider } from '../types'; +import { + RaRecord, + CreateParams, + GetListResult as OriginalGetListResult, + GetInfiniteListResult, + Identifier, + DataProvider, + MutationMode, +} from '../types'; import { useEvent } from '../util'; +import { useAddUndoableMutation } from './undo'; /** * Get a callback to call the dataProvider.create() method, the result and the loading state. @@ -80,12 +92,111 @@ export const useCreate = < ): UseCreateResult => { const dataProvider = useDataProvider(); const queryClient = useQueryClient(); + const addUndoableMutation = useAddUndoableMutation(); + const { data, meta } = params; + + const { + mutationMode = 'pessimistic', + getMutateWithMiddlewares, + ...mutationOptions + } = options; + const mode = useRef(mutationMode); const paramsRef = useRef>>>(params); - const hasCallTimeOnError = useRef(false); + const snapshot = useRef([]); + + // Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted + const mutateWithMiddlewares = useRef(dataProvider.create); + + // We need to store the call-time onError and onSettled in refs to be able to call them in the useMutation hook even + // when the calling component is unmounted + const callTimeOnError = + useRef< + UseCreateOptions< + RecordType, + MutationError, + ResultRecordType + >['onError'] + >(); + const callTimeOnSettled = + useRef< + UseCreateOptions< + RecordType, + MutationError, + ResultRecordType + >['onSettled'] + >(); + + // We don't need to keep a ref on the onSuccess callback as we call it ourselves for optimistic and + // undoable mutations. There is a limitation though: if one of the side effects applied by the onSuccess callback + // unmounts the component that called the useUpdate hook (redirect for instance), it must be the last one applied, + // otherwise the other side effects may not applied. const hasCallTimeOnSuccess = useRef(false); - const hasCallTimeOnSettled = useRef(false); - const { getMutateWithMiddlewares, ...mutationOptions } = options; + + const updateCache = ({ resource, id, data, meta }) => { + // hack: only way to tell react-query not to fetch this query for the next 5 seconds + // because setQueryData doesn't accept a stale time option + const now = Date.now(); + const updatedAt = mode.current === 'undoable' ? now + 5 * 1000 : now; + // Stringify and parse the data to remove undefined values. + // If we don't do this, an update with { id: undefined } as payload + // would remove the id from the record, which no real data provider does. + const clonedData = JSON.parse(JSON.stringify(data)); + + const updateColl = (old: RecordType[]) => { + if (!old) return old; + return [data, ...old]; + }; + + type GetListResult = Omit & { + data?: RecordType[]; + }; + + queryClient.setQueryData( + [resource, 'getOne', { id: String(id), meta }], + (record: RecordType) => ({ ...record, ...clonedData }), + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getList'] }, + (res: GetListResult) => + res && res.data ? { ...res, data: updateColl(res.data) } : res, + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getInfiniteList'] }, + ( + res: UseInfiniteQueryResult< + InfiniteData + >['data'] + ) => + res && res.pages + ? { + ...res, + pages: res.pages.map(page => ({ + ...page, + data: updateColl(page.data), + })), + } + : res, + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getMany'] }, + (coll: RecordType[]) => + coll && coll.length > 0 ? updateColl(coll) : coll, + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getManyReference'] }, + (res: GetListResult) => + res && res.data + ? { data: updateColl(res.data), total: res.total } + : res, + { updatedAt } + ); + }; + const mutation = useMutation< ResultRecordType, MutationError, @@ -106,63 +217,111 @@ export const useCreate = < 'useCreate mutation requires a non-empty data object' ); } - if (getMutateWithMiddlewares) { - const createWithMiddlewares = getMutateWithMiddlewares( - dataProvider.create.bind(dataProvider) - ); - return createWithMiddlewares(callTimeResource, { - data: callTimeData, - meta: callTimeMeta, - }).then(({ data }) => data); - } - return dataProvider - .create(callTimeResource, { + + return mutateWithMiddlewares + .current(callTimeResource, { data: callTimeData, meta: callTimeMeta, }) .then(({ data }) => data); }, ...mutationOptions, - onError: (error, variables, context) => { - if (options.onError && !hasCallTimeOnError.current) { - return options.onError(error, variables, context); + onMutate: async ( + variables: Partial> + ) => { + if (mutationOptions.onMutate) { + const userContext = + (await mutationOptions.onMutate(variables)) || {}; + return { + snapshot: snapshot.current, + // @ts-ignore + ...userContext, + }; + } else { + // Return a context object with the snapshot value + return { snapshot: snapshot.current }; + } + }, + onError: (error, variables, context: { snapshot: Snapshot }) => { + if (mode.current === 'optimistic' || mode.current === 'undoable') { + // If the mutation fails, use the context returned from onMutate to rollback + context.snapshot.forEach(([key, value]) => { + queryClient.setQueryData(key, value); + }); + } + if (callTimeOnError.current) { + return callTimeOnError.current(error, variables, context); } + if (mutationOptions.onError) { + return mutationOptions.onError(error, variables, context); + } + // call-time error callback is executed by react-query }, onSuccess: ( data: ResultRecordType, variables: Partial> = {}, context: unknown ) => { - const { resource: callTimeResource = resource } = variables; - queryClient.setQueryData( - [callTimeResource, 'getOne', { id: String(data.id) }], - data - ); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getList'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getInfiniteList'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getMany'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getManyReference'], - }); + if (mode.current === 'pessimistic') { + const { resource: callTimeResource = resource } = variables; + queryClient.setQueryData( + [callTimeResource, 'getOne', { id: String(data.id) }], + data + ); + queryClient.invalidateQueries({ + queryKey: [callTimeResource, 'getList'], + }); + queryClient.invalidateQueries({ + queryKey: [callTimeResource, 'getInfiniteList'], + }); + queryClient.invalidateQueries({ + queryKey: [callTimeResource, 'getMany'], + }); + queryClient.invalidateQueries({ + queryKey: [callTimeResource, 'getManyReference'], + }); - if (options.onSuccess && !hasCallTimeOnSuccess.current) { - options.onSuccess(data, variables, context); + if ( + mutationOptions.onSuccess && + !hasCallTimeOnSuccess.current + ) { + mutationOptions.onSuccess(data, variables, context); + } } }, - onSettled: (data, error, variables, context) => { - if (options.onSettled && !hasCallTimeOnSettled.current) { - return options.onSettled(data, error, variables, context); + onSettled: ( + data, + error, + variables, + context: { snapshot: Snapshot } + ) => { + if (mode.current === 'optimistic' || mode.current === 'undoable') { + // Always refetch after error or success: + context.snapshot.forEach(([queryKey]) => { + queryClient.invalidateQueries({ queryKey }); + }); + } + + if (callTimeOnSettled.current) { + return callTimeOnSettled.current( + data, + error, + variables, + context + ); + } + if (mutationOptions.onSettled) { + return mutationOptions.onSettled( + data, + error, + variables, + context + ); } }, }); - const create = ( + const create = async ( callTimeResource: string | undefined = resource, callTimeParams: Partial>> = {}, callTimeOptions: MutateOptions< @@ -170,27 +329,175 @@ export const useCreate = < MutationError, Partial>, unknown - > & { returnPromise?: boolean } = {} + > & { mutationMode?: MutationMode; returnPromise?: boolean } = {} ) => { const { - returnPromise = options.returnPromise, + mutationMode, + returnPromise = mutationOptions.returnPromise, + onError, + onSettled, + onSuccess, ...otherCallTimeOptions } = callTimeOptions; - hasCallTimeOnError.current = !!otherCallTimeOptions.onError; - hasCallTimeOnSuccess.current = !!otherCallTimeOptions.onSuccess; - hasCallTimeOnSettled.current = !!otherCallTimeOptions.onSettled; + // Store the mutation with middlewares to avoid losing them if the calling component is unmounted + if (getMutateWithMiddlewares) { + mutateWithMiddlewares.current = getMutateWithMiddlewares( + dataProvider.create.bind(dataProvider) + ); + } else { + mutateWithMiddlewares.current = dataProvider.create; + } + + // We need to keep the onSuccess callback here and not in the useMutation for undoable mutations + hasCallTimeOnSuccess.current = !!onSuccess; + // We need to store the onError and onSettled callbacks here to be able to call them in the useMutation hook + // so that they are called even when the calling component is unmounted + callTimeOnError.current = onError; + callTimeOnSettled.current = onSettled; + + // store the hook time params *at the moment of the call* + // because they may change afterwards, which would break the undoable mode + // as the previousData would be overwritten by the optimistic update + paramsRef.current = params; - if (returnPromise) { - return mutation.mutateAsync( + if (mutationMode) { + mode.current = mutationMode; + } + + if (returnPromise && mode.current !== 'pessimistic') { + console.warn( + 'The returnPromise parameter can only be used if the mutationMode is set to pessimistic' + ); + } + + if (mode.current === 'pessimistic') { + if (returnPromise) { + return mutation.mutateAsync( + { resource: callTimeResource, ...callTimeParams }, + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + { onSuccess, ...otherCallTimeOptions } + ); + } + return mutation.mutate( { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + { onSuccess, ...otherCallTimeOptions } ); } - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions + + const { data: callTimeData = data, meta: callTimeMeta = meta } = + callTimeParams; + const callTimeId = callTimeData?.id; + if (callTimeId == null) { + console.warn( + 'useCreate() data parameter must contain an id key when used with the optimistic or undoable modes' + ); + } + // optimistic create as documented in https://react-query-v3.tanstack.com/guides/optimistic-updates + // except we do it in a mutate wrapper instead of the onMutate callback + // to have access to success side effects + + const queryKeys = [ + [ + callTimeResource, + 'getOne', + { id: String(callTimeId), meta: callTimeMeta }, + ], + [callTimeResource, 'getList'], + [callTimeResource, 'getInfiniteList'], + [callTimeResource, 'getMany'], + [callTimeResource, 'getManyReference'], + ]; + + /** + * Snapshot the previous values via queryClient.getQueriesData() + * + * The snapshotData ref will contain an array of tuples [query key, associated data] + * + * @example + * [ + * [['posts', 'getOne', { id: '1' }], { id: 1, title: 'Hello' }], + * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], + * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], + * ] + * + * @see https://react-query-v3.tanstack.com/reference/QueryClient#queryclientgetqueriesdata + */ + snapshot.current = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot ); + + // Cancel any outgoing re-fetches (so they don't overwrite our optimistic update) + await Promise.all( + snapshot.current.map(([queryKey]) => + queryClient.cancelQueries({ queryKey }) + ) + ); + + // Optimistically update to the new value + updateCache({ + resource: callTimeResource, + id: callTimeId, + data: callTimeData, + meta: callTimeMeta, + }); + + // run the success callbacks during the next tick + setTimeout(() => { + if (onSuccess) { + onSuccess( + callTimeData as unknown as ResultRecordType, + { resource: callTimeResource, ...callTimeParams }, + { snapshot: snapshot.current } + ); + } else if ( + mutationOptions.onSuccess && + !hasCallTimeOnSuccess.current + ) { + mutationOptions.onSuccess( + callTimeData as unknown as ResultRecordType, + { resource: callTimeResource, ...callTimeParams }, + { snapshot: snapshot.current } + ); + } + }, 0); + + if (mode.current === 'optimistic') { + // call the mutate method without success side effects + return mutation.mutate({ + resource: callTimeResource, + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + ...callTimeParams, + }); + } else { + // Undoable mutation: add the mutation to the undoable queue. + // The Notification component will dequeue it when the user confirms or cancels the message. + addUndoableMutation(({ isUndo }) => { + if (isUndo) { + // rollback + queryClient.removeQueries({ + queryKey: [ + callTimeResource, + 'getOne', + { id: String(callTimeId), meta }, + ], + exact: true, + }); + snapshot.current.forEach(([key, value]) => { + queryClient.setQueryData(key, value); + }); + } else { + // call the mutate method without success side effects + mutation.mutate({ + resource: callTimeResource, + ...callTimeParams, + }); + } + }); + } }; const mutationResult = useMemo( @@ -204,6 +511,8 @@ export const useCreate = < return [useEvent(create), mutationResult]; }; +type Snapshot = [key: QueryKey, value: any][]; + export interface UseCreateMutateParams< RecordType extends Omit = any, > { @@ -224,6 +533,7 @@ export type UseCreateOptions< >, 'mutationFn' > & { + mutationMode?: MutationMode; returnPromise?: boolean; getMutateWithMiddlewares?: < CreateFunctionType extends @@ -248,8 +558,8 @@ export type CreateMutationFunction< MutationError, Partial>, unknown - > & { returnPromise?: TReturnPromise } -) => TReturnPromise extends true ? Promise : void; + > & { mutationMode?: MutationMode; returnPromise?: TReturnPromise } +) => Promise; export type UseCreateResult< RecordType extends Omit = any, diff --git a/packages/ra-core/src/dataProvider/useCreate.undoable.stories.tsx b/packages/ra-core/src/dataProvider/useCreate.undoable.stories.tsx new file mode 100644 index 00000000000..84468763da3 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useCreate.undoable.stories.tsx @@ -0,0 +1,507 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { QueryClient, useIsMutating } from '@tanstack/react-query'; + +import { CoreAdminContext } from '../core'; +import { useTakeUndoableMutation } from './undo'; +import { useCreate } from './useCreate'; +import { useGetOne } from './useGetOne'; + +export default { title: 'ra-core/dataProvider/useCreate/undoable' }; + +export const SuccessCase = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + posts.push(params.data); + resolve({ data: params.data }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const SuccessCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const takeMutation = useTakeUndoableMutation(); + const { data, error, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate(); + const handleClick = () => { + create( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + onSuccess: () => setSuccess('success'), + } + ); + setNotification(true); + }; + + return ( + <> + {error ? ( +

{error.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const ErrorCase = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const ErrorCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const takeMutation = useTakeUndoableMutation(); + const { data, error: getOneError, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate(); + const handleClick = () => { + create( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + setNotification(true); + }; + return ( + <> + {getOneError ? ( +

{getOneError.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {error &&
{error.message}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresSuccess = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + posts.push(params.data); + resolve({ data: params.data }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const takeMutation = useTakeUndoableMutation(); + const { data, error, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + // @ts-ignore + getMutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { + ...params.data, + title: `${params.data.title} from middleware`, + }, + }); + }, + } + ); + const handleClick = () => { + create( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + } + ); + setNotification(true); + }; + return ( + <> + {error ? ( +

{error.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return new Promise((resolve, reject) => { + const data = posts.find(p => p.id === params.id); + setTimeout(() => { + if (!data) { + reject(new Error('not found')); + } + resolve({ data }); + }, timeout); + }); + }, + create: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const takeMutation = useTakeUndoableMutation(); + const { data, error: getOneError, refetch } = useGetOne('posts', { id: 2 }); + const [create, { isPending }] = useCreate( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + // @ts-ignore + getMutateWithMiddlewares: mutate => async (resource, params) => { + return mutate(resource, { + ...params, + data: { + ...params.data, + title: `${params.data.title} from middleware`, + }, + }); + }, + } + ); + const handleClick = () => { + create( + 'posts', + { + data: { id: 2, title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + setNotification(true); + }; + return ( + <> + {getOneError ? ( +

{getOneError.message}

+ ) : ( +
+
id
+
{data?.id}
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+ )} +
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {error &&
{error.message}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-ui-materialui/src/button/CreateButton.stories.tsx b/packages/ra-ui-materialui/src/button/CreateButton.stories.tsx index 2b1d37519b2..7a8b613ac1b 100644 --- a/packages/ra-ui-materialui/src/button/CreateButton.stories.tsx +++ b/packages/ra-ui-materialui/src/button/CreateButton.stories.tsx @@ -50,6 +50,7 @@ const AccessControlAdmin = ({ queryClient }: { queryClient: QueryClient }) => { const [resourcesAccesses, setResourcesAccesses] = React.useState({ 'books.list': true, 'books.create': false, + 'books.delete': false, }); const authProvider: AuthProvider = { diff --git a/packages/ra-ui-materialui/src/detail/Create.tsx b/packages/ra-ui-materialui/src/detail/Create.tsx index 8f29dbf0b9e..80517d67e11 100644 --- a/packages/ra-ui-materialui/src/detail/Create.tsx +++ b/packages/ra-ui-materialui/src/detail/Create.tsx @@ -66,6 +66,7 @@ export const Create = < record, redirect, transform, + mutationMode, mutationOptions, disableAuthentication, hasEdit, @@ -79,6 +80,7 @@ export const Create = < record={record} redirect={redirect} transform={transform} + mutationMode={mutationMode} mutationOptions={mutationOptions} disableAuthentication={disableAuthentication} hasEdit={hasEdit} diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 6d2677c252a..6d71afff7be 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -859,7 +859,7 @@ describe('', () => { expect(onSuccess).toHaveBeenCalledWith( expect.objectContaining({ gender: null }), expect.anything(), - undefined + { snapshot: [] } ); }); }); diff --git a/packages/react-admin/src/Admin.tsx b/packages/react-admin/src/Admin.tsx index e08a345d726..e519bfc6066 100644 --- a/packages/react-admin/src/Admin.tsx +++ b/packages/react-admin/src/Admin.tsx @@ -115,6 +115,7 @@ export const Admin = (props: AdminProps) => { loginPage, notification, queryClient, + QueryClientProvider, ready, requireAuth, store = defaultStore, @@ -138,6 +139,7 @@ export const Admin = (props: AdminProps) => { i18nProvider={i18nProvider} lightTheme={lightTheme} queryClient={queryClient} + QueryClientProvider={QueryClientProvider} store={store} theme={theme} >