diff --git a/packages/ra-core/src/controller/edit/EditBase.spec.tsx b/packages/ra-core/src/controller/edit/EditBase.spec.tsx index 8c56b9632d4..bde8a29df66 100644 --- a/packages/ra-core/src/controller/edit/EditBase.spec.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.spec.tsx @@ -81,7 +81,7 @@ describe('EditBase', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); @@ -125,7 +125,7 @@ describe('EditBase', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); expect(onSuccess).not.toHaveBeenCalled(); @@ -162,7 +162,7 @@ describe('EditBase', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); @@ -199,7 +199,7 @@ describe('EditBase', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); expect(onError).not.toHaveBeenCalled(); diff --git a/packages/ra-core/src/dataProvider/index.ts b/packages/ra-core/src/dataProvider/index.ts index ed5bd65b373..23fdf027cfe 100644 --- a/packages/ra-core/src/dataProvider/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -26,6 +26,7 @@ export * from './useDelete'; export * from './useDeleteMany'; export * from './useInfiniteGetList'; export * from './undo/'; +export * from './useMutationWithMutationMode'; export type { Options } from './fetch'; diff --git a/packages/ra-core/src/dataProvider/useCreate.spec.tsx b/packages/ra-core/src/dataProvider/useCreate.spec.tsx index 44937381fa3..1c938488b29 100644 --- a/packages/ra-core/src/dataProvider/useCreate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useCreate.spec.tsx @@ -420,6 +420,7 @@ describe('useCreate', () => { describe('middlewares', () => { it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); render(); screen.getByText('Create post').click(); await waitFor(() => { diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index 0ea0282e3a1..f4d368b3e23 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -1,15 +1,12 @@ -import { useEffect, useMemo, useRef } from 'react'; import { - useMutation, - UseMutationOptions, - UseMutationResult, useQueryClient, - MutateOptions, - QueryKey, + type UseMutationOptions, + type UseMutationResult, + type MutateOptions, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { +import type { RaRecord, CreateParams, Identifier, @@ -17,7 +14,10 @@ import { MutationMode, } from '../types'; import { useEvent } from '../util'; -import { useAddUndoableMutation } from './undo'; +import { + type Snapshot, + useMutationWithMutationMode, +} from './useMutationWithMutationMode'; /** * Get a callback to call the dataProvider.create() method, the result and the loading state. @@ -88,389 +88,164 @@ 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); - useEffect(() => { - mode.current = mutationMode; - }, [mutationMode]); - - const paramsRef = - useRef>>>(params); - useEffect(() => { - paramsRef.current = params; - }, [params]); - - 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< + const dataProviderCreate = useEvent((resource: string, params) => + dataProvider + .create< 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 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)); - - queryClient.setQueryData( - [resource, 'getOne', { id: String(id), meta }], - (record: RecordType) => ({ ...record, ...clonedData }), - { updatedAt } - ); - }; + >(resource, params as CreateParams) + .then(({ data }) => data) + ); - const mutation = useMutation< - ResultRecordType, + const [mutate, mutationResult] = useMutationWithMutationMode< MutationError, - Partial> - >({ - mutationKey: [resource, 'create', params], - mutationFn: ({ - resource: callTimeResource = resource, - data: callTimeData = paramsRef.current.data, - meta: callTimeMeta = paramsRef.current.meta, - } = {}) => { - if (!callTimeResource) { - throw new Error( - 'useCreate mutation requires a non-empty resource' - ); - } - if (!callTimeData) { - throw new Error( - 'useCreate mutation requires a non-empty data object' + ResultRecordType, + UseCreateMutateParams + >( + { resource, ...params }, + { + ...mutationOptions, + mutationKey: [resource, 'create', params], + mutationMode, + mutationFn: ({ resource, ...params }) => { + if (resource == null) { + throw new Error('useCreate mutation requires a resource'); + } + if (params == null) { + throw new Error('useCreate mutation requires parameters'); + } + return dataProviderCreate(resource, params); + }, + updateCache: ( + { resource, ...params }, + { mutationMode }, + result + ) => { + const id = + mutationMode === 'pessimistic' + ? result?.id + : params.data?.id; + if (!id) { + return undefined; + } + // 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 = + mutationMode === '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( + mutationMode === 'pessimistic' ? result : params.data + ) ); - } - return mutateWithMiddlewares - .current(callTimeResource, { - data: callTimeData, - meta: callTimeMeta, - }) - .then(({ data }) => data); - }, - ...mutationOptions, - 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 - ) => { - if (mode.current === 'pessimistic') { - const { resource: callTimeResource = resource } = variables; queryClient.setQueryData( - [callTimeResource, 'getOne', { id: String(data.id) }], - data + [resource, 'getOne', { id: String(id), meta: params.meta }], + (record: RecordType) => ({ ...record, ...clonedData }), + { updatedAt } ); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getList'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getInfiniteList'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getMany'], - }); - queryClient.invalidateQueries({ - queryKey: [callTimeResource, 'getManyReference'], - }); - if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess(data, variables, context); + return clonedData; + }, + getSnapshot: ({ resource, ...params }, { mutationMode }) => { + const queryKeys: any[] = [ + [resource, 'getList'], + [resource, 'getInfiniteList'], + [resource, 'getMany'], + [resource, 'getManyReference'], + ]; + + if (mutationMode !== 'pessimistic' && params.data?.id) { + queryKeys.push([ + resource, + 'getOne', + { id: String(params.data.id), meta: params.meta }, + ]); } - } - }, - 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 + /** + * 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 + */ + const snapshot = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot ); - } - }, - }); - const create = async ( - callTimeResource: string | undefined = resource, - callTimeParams: Partial>> = {}, - callTimeOptions: MutateOptions< - ResultRecordType, - MutationError, - Partial>, - unknown - > & { mutationMode?: MutationMode; returnPromise?: boolean } = {} - ) => { - const { - mutationMode, - returnPromise = mutationOptions.returnPromise, - onError, - onSettled, - onSuccess, - ...otherCallTimeOptions - } = callTimeOptions; - - // 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 (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 }, - // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects - { onSuccess, ...otherCallTimeOptions } - ); + return snapshot; + }, + getMutateWithMiddlewares: mutationFn => args => { + // This is necessary to avoid breaking changes in useCreate: + // The mutation function must have the same signature as before (resource, params) and not ({ resource, params }) + if (getMutateWithMiddlewares) { + const { resource, ...params } = args; + return getMutateWithMiddlewares( + dataProviderCreate.bind(dataProvider) + )(resource, params); + } + return mutationFn(args); + }, + onUndo: ({ resource, data, meta }) => { + queryClient.removeQueries({ + queryKey: [ + resource, + 'getOne', + { id: String(data?.id), meta }, + ], + exact: true, + }); + }, } + ); - 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' + const create = useEvent( + ( + callTimeResource: string | undefined = resource, + callTimeParams: Partial> = {}, + callTimeOptions: MutateOptions< + ResultRecordType, + MutationError, + Partial>, + unknown + > & { + mutationMode?: MutationMode; + returnPromise?: boolean; + } = {} + ) => { + return mutate( + { + resource: callTimeResource, + ...callTimeParams, + }, + callTimeOptions ); } - // 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( - () => ({ - isLoading: mutation.isPending, - ...mutation, - }), - [mutation] ); - return [useEvent(create), mutationResult]; + return [create, mutationResult]; }; -type Snapshot = [key: QueryKey, value: any][]; - export interface UseCreateMutateParams< RecordType extends Omit = any, > { diff --git a/packages/ra-core/src/dataProvider/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts index 3847b696366..5b1d8858310 100644 --- a/packages/ra-core/src/dataProvider/useDelete.ts +++ b/packages/ra-core/src/dataProvider/useDelete.ts @@ -1,24 +1,24 @@ -import { useEffect, useMemo, useRef } from 'react'; import { - useMutation, useQueryClient, - UseMutationOptions, - UseMutationResult, - MutateOptions, - QueryKey, - UseInfiniteQueryResult, - InfiniteData, + type UseMutationOptions, + type UseMutationResult, + type MutateOptions, + type UseInfiniteQueryResult, + type InfiniteData, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { useAddUndoableMutation } from './undo/useAddUndoableMutation'; -import { +import type { RaRecord, DeleteParams, MutationMode, GetListResult as OriginalGetListResult, GetInfiniteListResult, } from '../types'; +import { + type Snapshot, + useMutationWithMutationMode, +} from './useMutationWithMutationMode'; import { useEvent } from '../util'; /** @@ -89,372 +89,185 @@ export const useDelete = < ): UseDeleteResult => { const dataProvider = useDataProvider(); const queryClient = useQueryClient(); - const addUndoableMutation = useAddUndoableMutation(); - const { id, previousData } = params; const { mutationMode = 'pessimistic', ...mutationOptions } = options; - const mode = useRef(mutationMode); - useEffect(() => { - mode.current = mutationMode; - }, [mutationMode]); - - const paramsRef = useRef>>(params); - useEffect(() => { - paramsRef.current = params; - }, [params]); - - const snapshot = useRef([]); - const hasCallTimeOnError = useRef(false); - const hasCallTimeOnSuccess = useRef(false); - const hasCallTimeOnSettled = useRef(false); - - const updateCache = ({ resource, id }) => { - // 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; + const [mutate, mutationResult] = useMutationWithMutationMode< + MutationError, + RecordType | undefined, + UseDeleteMutateParams + >( + { resource, ...params }, + { + ...mutationOptions, + mutationKey: [resource, 'delete', params], + mutationMode, + mutationFn: ({ resource, ...params }) => { + if (resource == null) { + throw new Error('useDelete mutation requires a resource'); + } + if (params == null) { + throw new Error('useDelete mutation requires parameters'); + } + return dataProvider + .delete( + resource, + params as DeleteParams + ) + .then(({ data }) => data); + }, + updateCache: ({ resource, ...params }, { mutationMode }) => { + // 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 = + mutationMode === 'undoable' ? now + 5 * 1000 : now; - const updateColl = (old: RecordType[]) => { - if (!old) return old; - const index = old.findIndex( - // eslint-disable-next-line eqeqeq - record => record.id == id - ); - if (index === -1) { - return old; - } - return [...old.slice(0, index), ...old.slice(index + 1)]; - }; + const updateColl = (old: RecordType[]) => { + if (!old) return old; + const index = old.findIndex( + // eslint-disable-next-line eqeqeq + record => record.id == params.id + ); + if (index === -1) { + return old; + } + return [...old.slice(0, index), ...old.slice(index + 1)]; + }; - type GetListResult = Omit & { - data?: RecordType[]; - }; + type GetListResult = Omit & { + data?: RecordType[]; + }; - queryClient.setQueriesData( - { queryKey: [resource, 'getList'] }, - (res: GetListResult) => { - if (!res || !res.data) return res; - const newCollection = updateColl(res.data); - const recordWasFound = newCollection.length < res.data.length; - return recordWasFound - ? { - data: newCollection, - total: res.total ? res.total - 1 : undefined, - pageInfo: res.pageInfo, - } - : res; - }, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getInfiniteList'] }, - ( - res: UseInfiniteQueryResult< - InfiniteData - >['data'] - ) => { - if (!res || !res.pages) return res; - return { - ...res, - pages: res.pages.map(page => { - const newCollection = updateColl(page.data); + queryClient.setQueriesData( + { queryKey: [resource, 'getList'] }, + (res: GetListResult) => { + if (!res || !res.data) return res; + const newCollection = updateColl(res.data); const recordWasFound = - newCollection.length < page.data.length; + newCollection.length < res.data.length; return recordWasFound ? { - ...page, data: newCollection, - total: page.total - ? page.total - 1 - : undefined, - pageInfo: page.pageInfo, + total: res.total ? res.total - 1 : undefined, + pageInfo: res.pageInfo, } - : page; - }), - }; - }, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getMany'] }, - (coll: RecordType[]) => - coll && coll.length > 0 ? updateColl(coll) : coll, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getManyReference'] }, - (res: GetListResult) => { - if (!res || !res.data) return res; - const newCollection = updateColl(res.data); - const recordWasFound = newCollection.length < res.data.length; - return recordWasFound - ? { - data: newCollection, - total: res.total! - 1, - } - : res; - }, - { updatedAt } - ); - }; - - const mutation = useMutation< - RecordType, - MutationError, - Partial> - >({ - mutationKey: [resource, 'delete', params], - mutationFn: ({ - resource: callTimeResource = resource, - id: callTimeId = paramsRef.current.id, - previousData: callTimePreviousData = paramsRef.current.previousData, - meta: callTimeMeta = paramsRef.current.meta, - } = {}) => { - if (!callTimeResource) { - throw new Error( - 'useDelete mutation requires a non-empty resource' + : res; + }, + { updatedAt } ); - } - if (callTimeId == null) { - throw new Error('useDelete mutation requires a non-empty id'); - } - return dataProvider - .delete(callTimeResource, { - id: callTimeId, - previousData: callTimePreviousData, - meta: callTimeMeta, - }) - .then(({ data }) => data); - }, - ...mutationOptions, - 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: MutationError, - variables: Partial> = {}, - 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 (mutationOptions.onError && !hasCallTimeOnError.current) { - return mutationOptions.onError(error, variables, context); - } - // call-time error callback is executed by react-query - }, - onSuccess: ( - data: RecordType, - variables: Partial> = {}, - context: unknown - ) => { - if (mode.current === 'pessimistic') { - // update the getOne and getList query cache with the new result - const { - resource: callTimeResource = resource, - id: callTimeId = id, - } = variables; - updateCache({ - resource: callTimeResource, - id: callTimeId, - }); - - if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess(data, variables, context); - } - // call-time success callback is executed by react-query - } - }, - onSettled: ( - data: RecordType, - error: MutationError, - variables: Partial> = {}, - context: { snapshot: Snapshot } - ) => { - // Always refetch after error or success: - context.snapshot.forEach(([queryKey]) => { - queryClient.invalidateQueries({ queryKey }); - }); - - if (mutationOptions.onSettled && !hasCallTimeOnSettled.current) { - return mutationOptions.onSettled( - data, - error, - variables, - context + queryClient.setQueriesData( + { queryKey: [resource, 'getInfiniteList'] }, + ( + res: UseInfiniteQueryResult< + InfiniteData + >['data'] + ) => { + if (!res || !res.pages) return res; + return { + ...res, + pages: res.pages.map(page => { + const newCollection = updateColl(page.data); + const recordWasFound = + newCollection.length < page.data.length; + return recordWasFound + ? { + ...page, + data: newCollection, + total: page.total + ? page.total - 1 + : undefined, + pageInfo: page.pageInfo, + } + : page; + }), + }; + }, + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getMany'] }, + (coll: RecordType[]) => + coll && coll.length > 0 ? updateColl(coll) : coll, + { updatedAt } + ); + queryClient.setQueriesData( + { queryKey: [resource, 'getManyReference'] }, + (res: GetListResult) => { + if (!res || !res.data) return res; + const newCollection = updateColl(res.data); + const recordWasFound = + newCollection.length < res.data.length; + return recordWasFound + ? { + data: newCollection, + total: res.total! - 1, + } + : res; + }, + { updatedAt } ); - } - }, - }); - - const mutate = async ( - callTimeResource: string | undefined = resource, - callTimeParams: Partial> = {}, - callTimeOptions: MutateOptions< - RecordType, - MutationError, - Partial>, - unknown - > & { - mutationMode?: MutationMode; - onSuccess?: ( - data: RecordType | undefined, - variables: Partial>, - context: unknown - ) => void; - } = {} - ) => { - const { mutationMode, ...otherCallTimeOptions } = callTimeOptions; - hasCallTimeOnError.current = !!callTimeOptions.onError; - hasCallTimeOnSuccess.current = !!callTimeOptions.onSuccess; - hasCallTimeOnSettled.current = !!callTimeOptions.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 (mutationMode) { - mode.current = mutationMode; - } - - const { - id: callTimeId = id, - previousData: callTimePreviousData = previousData, - } = callTimeParams; - - // optimistic update as documented in https://react-query-v5.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, 'getList'], - [callTimeResource, 'getInfiniteList'], - [callTimeResource, 'getMany'], - [callTimeResource, 'getManyReference'], - ]; + return params.previousData; + }, + getSnapshot: ({ resource }) => { + const queryKeys = [ + [resource, 'getList'], + [resource, 'getInfiniteList'], + [resource, 'getMany'], + [resource, 'getManyReference'], + ]; - /** - * Snapshot the previous values via queryClient.getQueriesData() - * - * The snapshotData ref will contain an array of tuples [query key, associated data] - * - * @example - * [ - * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], - * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], - * ] - * - * @see https://tanstack.com/query/v5/docs/react/reference/QueryClient#queryclientgetqueriesdata - */ - snapshot.current = queryKeys.reduce( - (prev, queryKey) => - prev.concat(queryClient.getQueriesData({ queryKey })), - [] as Snapshot - ); + /** + * Snapshot the previous values via queryClient.getQueriesData() + * + * The snapshotData ref will contain an array of tuples [query key, associated data] + * + * @example + * [ + * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], + * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], + * ] + * + * @see https://tanstack.com/query/v5/docs/react/reference/QueryClient#queryclientgetqueriesdata + */ + const snapshot = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot + ); - if (mode.current === 'pessimistic') { - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions - ); + return 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, - }); - - // run the success callbacks during the next tick - setTimeout(() => { - if (callTimeOptions.onSuccess) { - callTimeOptions.onSuccess( - callTimePreviousData, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } - ); - } else if (mutationOptions.onSuccess) { - mutationOptions.onSuccess( - callTimePreviousData, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } - ); - } - }, 0); - - if (mode.current === 'optimistic') { - // call the mutate method without success side effects - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, + const deleteOne = useEvent( + ( + callTimeResource: string | undefined = resource, + callTimeParams: Partial> = {}, + callTimeOptions: MutateOptions< + RecordType | undefined, + MutationError, + Partial>, + unknown + > & { + mutationMode?: MutationMode; + returnPromise?: boolean; + } = {} + ) => { + return mutate( { - onSettled: callTimeOptions.onSettled, - onError: callTimeOptions.onError, - } + resource: callTimeResource, + ...callTimeParams, + }, + callTimeOptions ); - } 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 - snapshot.current.forEach(([key, value]) => { - queryClient.setQueryData(key, value); - }); - } else { - // call the mutate method without success side effects - mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSettled: callTimeOptions.onSettled, - onError: callTimeOptions.onError, - } - ); - } - }); } - }; - - const mutationResult = useMemo( - () => ({ - isLoading: mutation.isPending, - ...mutation, - }), - [mutation] ); - return [useEvent(mutate), mutationResult]; + return [deleteOne, mutationResult]; }; -type Snapshot = [key: QueryKey, value: any][]; - export interface UseDeleteMutateParams { resource?: string; id?: RecordType['id']; @@ -469,9 +282,10 @@ export type UseDeleteOptions< > = UseMutationOptions< RecordType, MutationError, - Partial> + Partial, 'mutationFn'>> > & { mutationMode?: MutationMode; + returnPromise?: boolean; onSuccess?: ( data: RecordType | undefined, variables: Partial>, @@ -482,21 +296,23 @@ export type UseDeleteOptions< export type UseDeleteResult< RecordType extends RaRecord = any, MutationError = unknown, + TReturnPromise extends boolean = boolean, > = [ ( resource?: string, params?: Partial>, options?: MutateOptions< - RecordType, + RecordType | undefined, MutationError, Partial>, unknown > & { mutationMode?: MutationMode; + returnPromise?: TReturnPromise; } - ) => Promise, + ) => Promise, UseMutationResult< - RecordType, + RecordType | undefined, MutationError, Partial & { resource?: string }>, unknown diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts index 4b7d4080132..3c858aee1cb 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.ts +++ b/packages/ra-core/src/dataProvider/useDeleteMany.ts @@ -1,18 +1,14 @@ -import { useEffect, useMemo, useRef } from 'react'; import { - useMutation, useQueryClient, - UseMutationOptions, - UseMutationResult, - MutateOptions, - QueryKey, - UseInfiniteQueryResult, - InfiniteData, + type UseMutationOptions, + type UseMutationResult, + type MutateOptions, + type UseInfiniteQueryResult, + type InfiniteData, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { useAddUndoableMutation } from './undo/useAddUndoableMutation'; -import { +import type { RaRecord, DeleteManyParams, MutationMode, @@ -20,6 +16,10 @@ import { GetInfiniteListResult, } from '../types'; import { useEvent } from '../util'; +import { + type Snapshot, + useMutationWithMutationMode, +} from './useMutationWithMutationMode'; /** * Get a callback to call the dataProvider.delete() method, the result and the loading state. @@ -89,430 +89,249 @@ export const useDeleteMany = < ): UseDeleteManyResult => { const dataProvider = useDataProvider(); const queryClient = useQueryClient(); - const addUndoableMutation = useAddUndoableMutation(); - const { ids } = params; const { mutationMode = 'pessimistic', ...mutationOptions } = options; - const mode = useRef(mutationMode); - useEffect(() => { - mode.current = mutationMode; - }, [mutationMode]); - - const paramsRef = useRef>>({}); - useEffect(() => { - paramsRef.current = params; - }, [params]); - - const snapshot = useRef([]); - const hasCallTimeOnError = useRef(false); - const hasCallTimeOnSuccess = useRef(false); - const hasCallTimeOnSettled = useRef(false); - - const updateCache = ({ resource, ids }) => { - // 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; - - const updateColl = (old: RecordType[]) => { - if (!old) return old; - let newCollection = [...old]; - ids.forEach(id => { - const index = newCollection.findIndex( - // eslint-disable-next-line eqeqeq - record => record.id == id - ); - if (index === -1) { - return; + const [mutate, mutationResult] = useMutationWithMutationMode< + MutationError, + Array | undefined, + UseDeleteManyMutateParams + >( + { resource, ...params }, + { + ...mutationOptions, + mutationKey: [resource, 'deleteMany', params], + mutationMode, + mutationFn: ({ resource, ...params }) => { + if (resource == null) { + throw new Error( + 'useDeleteMany mutation requires a resource' + ); } - newCollection = [ - ...newCollection.slice(0, index), - ...newCollection.slice(index + 1), - ]; - }); - return newCollection; - }; + if (params == null) { + throw new Error( + 'useDeleteMany mutation requires parameters' + ); + } + return dataProvider + .deleteMany( + resource, + params as DeleteManyParams + ) + .then(({ data }) => data); + }, + updateCache: ({ resource, ...params }, { mutationMode }) => { + // 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 = + mutationMode === 'undoable' ? now + 5 * 1000 : now; - type GetListResult = Omit & { - data?: RecordType[]; - }; + const updateColl = (old: RecordType[]) => { + if (!old) return old; + let newCollection = [...old]; + params.ids?.forEach(id => { + const index = newCollection.findIndex( + // eslint-disable-next-line eqeqeq + record => record.id == id + ); + if (index === -1) { + return; + } + newCollection = [ + ...newCollection.slice(0, index), + ...newCollection.slice(index + 1), + ]; + }); + return newCollection; + }; - queryClient.setQueriesData( - { queryKey: [resource, 'getList'] }, - (res: GetListResult) => { - if (!res || !res.data) return res; - const newCollection = updateColl(res.data); - const recordWasFound = newCollection.length < res.data.length; - return recordWasFound - ? { - data: newCollection, - total: res.total - ? res.total - - (res.data.length - newCollection.length) - : undefined, - pageInfo: res.pageInfo, - } - : res; - }, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getInfiniteList'] }, - ( - res: UseInfiniteQueryResult< - InfiniteData - >['data'] - ) => { - if (!res || !res.pages) return res; - return { - ...res, - pages: res.pages.map(page => { - const newCollection = updateColl(page.data); + type GetListResult = Omit & { + data?: RecordType[]; + }; + + queryClient.setQueriesData( + { queryKey: [resource, 'getList'] }, + (res: GetListResult) => { + if (!res || !res.data) return res; + const newCollection = updateColl(res.data); const recordWasFound = - newCollection.length < page.data.length; + newCollection.length < res.data.length; return recordWasFound ? { - ...page, data: newCollection, - total: page.total - ? page.total - - (page.data.length - - newCollection.length) + total: res.total + ? res.total - + (res.data.length - newCollection.length) : undefined, - pageInfo: page.pageInfo, + pageInfo: res.pageInfo, } - : page; - }), - }; - }, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getMany'] }, - (coll: RecordType[]) => - coll && coll.length > 0 ? updateColl(coll) : coll, - { updatedAt } - ); - queryClient.setQueriesData( - { queryKey: [resource, 'getManyReference'] }, - (res: GetListResult) => { - if (!res || !res.data) return res; - const newCollection = updateColl(res.data); - const recordWasFound = newCollection.length < res.data.length; - if (!recordWasFound) { - return res; - } - if (res.total) { - return { - data: newCollection, - total: - res.total - - (res.data.length - newCollection.length), - }; - } - if (res.pageInfo) { - return { - data: newCollection, - pageInfo: res.pageInfo, - }; - } - throw new Error( - 'Found getList result in cache without total or pageInfo' + : res; + }, + { updatedAt } ); - }, - { updatedAt } - ); - }; - - const mutation = useMutation< - RecordType['id'][], - MutationError, - Partial> - >({ - mutationKey: [resource, 'deleteMany', params], - mutationFn: ({ - resource: callTimeResource = resource, - ids: callTimeIds = paramsRef.current.ids, - meta: callTimeMeta = paramsRef.current.meta, - } = {}) => { - if (!callTimeResource) { - throw new Error( - 'useDeleteMany mutation requires a non-empty resource' + queryClient.setQueriesData( + { queryKey: [resource, 'getInfiniteList'] }, + ( + res: UseInfiniteQueryResult< + InfiniteData + >['data'] + ) => { + if (!res || !res.pages) return res; + return { + ...res, + pages: res.pages.map(page => { + const newCollection = updateColl(page.data); + const recordWasFound = + newCollection.length < page.data.length; + return recordWasFound + ? { + ...page, + data: newCollection, + total: page.total + ? page.total - + (page.data.length - + newCollection.length) + : undefined, + pageInfo: page.pageInfo, + } + : page; + }), + }; + }, + { updatedAt } ); - } - if (!callTimeIds) { - throw new Error( - 'useDeleteMany mutation requires an array of ids' + queryClient.setQueriesData( + { queryKey: [resource, 'getMany'] }, + (coll: RecordType[]) => + coll && coll.length > 0 ? updateColl(coll) : coll, + { updatedAt } ); - } - if (callTimeIds.length === 0) { - return Promise.resolve([]); - } - return dataProvider - .deleteMany(callTimeResource, { - ids: callTimeIds, - meta: callTimeMeta, - }) - .then(({ data }) => data || []); - }, - ...mutationOptions, - 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: MutationError, - variables: Partial> = {}, - 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 (mutationOptions.onError && !hasCallTimeOnError.current) { - return mutationOptions.onError(error, variables, context); - } - // call-time error callback is executed by react-query - }, - onSuccess: ( - data: RecordType['id'][], - variables: Partial> = {}, - context: unknown - ) => { - if (mode.current === 'pessimistic') { - // update the getOne and getList query cache with the new result - const { - resource: callTimeResource = resource, - ids: callTimeIds = ids, - } = variables; - updateCache({ - resource: callTimeResource, - ids: callTimeIds, - }); - - if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess(data, variables, context); - } - // call-time success callback is executed by react-query - } - }, - onSettled: ( - data: RecordType['id'][], - error: MutationError, - variables: Partial> = {}, - context: { snapshot: Snapshot } - ) => { - if (mode.current === 'optimistic' || mode.current === 'undoable') { - // Always refetch after error or success: - context.snapshot.forEach(([queryKey]) => { - queryClient.invalidateQueries({ queryKey }); - }); - } - - if (mutationOptions.onSettled && !hasCallTimeOnSettled.current) { - return mutationOptions.onSettled( - data, - error, - variables, - context + queryClient.setQueriesData( + { queryKey: [resource, 'getManyReference'] }, + (res: GetListResult) => { + if (!res || !res.data) return res; + const newCollection = updateColl(res.data); + const recordWasFound = + newCollection.length < res.data.length; + if (!recordWasFound) { + return res; + } + if (res.total) { + return { + data: newCollection, + total: + res.total - + (res.data.length - newCollection.length), + }; + } + if (res.pageInfo) { + return { + data: newCollection, + pageInfo: res.pageInfo, + }; + } + throw new Error( + 'Found getList result in cache without total or pageInfo' + ); + }, + { updatedAt } ); - } - }, - }); - - const mutate = async ( - callTimeResource: string | undefined = resource, - callTimeParams: Partial> = {}, - callTimeOptions: MutateOptions< - RecordType['id'][], - unknown, - Partial>, - unknown - > & { mutationMode?: MutationMode } = {} - ) => { - const { mutationMode, ...otherCallTimeOptions } = callTimeOptions; - hasCallTimeOnError.current = !!callTimeOptions.onError; - hasCallTimeOnSuccess.current = !!callTimeOptions.onSuccess; - hasCallTimeOnSettled.current = !!callTimeOptions.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 (mutationMode) { - mode.current = mutationMode; - } - - if (mode.current === 'pessimistic') { - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSuccess: otherCallTimeOptions.onSuccess, - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } - ); - } - - const { ids: callTimeIds = ids } = callTimeParams; - if (!callTimeIds) { - throw new Error('useDeleteMany mutation requires an array of ids'); - } - - // optimistic update as documented in https://react-query-v5.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, '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', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], - * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], - * ] - * - * @see https://tanstack.com/query/v5/docs/react/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, - ids: callTimeIds, - }); + return params.ids; + }, + getSnapshot: ({ resource }) => { + const queryKeys = [ + [resource, 'getList'], + [resource, 'getInfiniteList'], + [resource, 'getMany'], + [resource, 'getManyReference'], + ]; - // run the success callbacks during the next tick - setTimeout(() => { - if (otherCallTimeOptions.onSuccess) { - otherCallTimeOptions.onSuccess( - callTimeIds, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } + /** + * Snapshot the previous values via queryClient.getQueriesData() + * + * The snapshotData ref will contain an array of tuples [query key, associated data] + * + * @example + * [ + * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], + * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], + * ] + * + * @see https://tanstack.com/query/v5/docs/react/reference/QueryClient#queryclientgetqueriesdata + */ + const snapshot = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot ); - } else if (mutationOptions.onSuccess) { - mutationOptions.onSuccess( - callTimeIds, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } - ); - } - }, 0); + return snapshot; + }, + } + ); - if (mode.current === 'optimistic') { - // call the mutate method without success side effects - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, + const deleteMany = useEvent( + ( + callTimeResource: string | undefined = resource, + callTimeParams: Partial> = {}, + callTimeOptions: MutateOptions< + Array, + MutationError, + Partial>, + unknown + > & { + mutationMode?: MutationMode; + returnPromise?: boolean; + } = {} + ) => { + return mutate( { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } + resource: callTimeResource, + ...callTimeParams, + }, + callTimeOptions ); - } 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 - snapshot.current.forEach(([key, value]) => { - queryClient.setQueryData(key, value); - }); - } else { - // call the mutate method without success side effects - mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } - ); - } - }); } - }; - - const mutationResult = useMemo( - () => ({ - isLoading: mutation.isPending, - ...mutation, - }), - [mutation] ); - return [useEvent(mutate), mutationResult]; + return [deleteMany, mutationResult]; }; -type Snapshot = [key: QueryKey, value: any][]; - export interface UseDeleteManyMutateParams { resource?: string; - ids?: RecordType['id'][]; + ids?: Array; meta?: any; } export type UseDeleteManyOptions< RecordType extends RaRecord = any, MutationError = unknown, + TReturnPromise extends boolean = boolean, > = UseMutationOptions< - RecordType['id'][], + Array | undefined, MutationError, Partial> -> & { mutationMode?: MutationMode }; +> & { mutationMode?: MutationMode; returnPromise?: TReturnPromise }; export type UseDeleteManyResult< RecordType extends RaRecord = any, MutationError = unknown, + TReturnPromise extends boolean = boolean, > = [ ( resource?: string, params?: Partial>, options?: MutateOptions< - RecordType['id'][], + Array | undefined, MutationError, Partial>, unknown - > & { mutationMode?: MutationMode } - ) => Promise, + > & { mutationMode?: MutationMode; returnPromise?: TReturnPromise } + ) => Promise< + TReturnPromise extends true ? Array | undefined : void + >, UseMutationResult< - RecordType['id'][], + Array | undefined, MutationError, Partial & { resource?: string }>, unknown diff --git a/packages/ra-core/src/dataProvider/useMutationWithMutationMode.spec.tsx b/packages/ra-core/src/dataProvider/useMutationWithMutationMode.spec.tsx new file mode 100644 index 00000000000..aaf07148713 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useMutationWithMutationMode.spec.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import expect from 'expect'; +import { useMutationWithMutationMode } from './useMutationWithMutationMode'; +import { CoreAdminContext } from '../core/CoreAdminContext'; +import { useDataProvider } from './useDataProvider'; +import { DataProvider } from '../types'; +import { testDataProvider } from './testDataProvider'; + +describe('useMutationWithMutationMode', () => { + type MyDataProvider = DataProvider & { + updateUserProfile: ({ data }: { data: any }) => Promise<{ data: any }>; + }; + + const useUpdateUserProfile = (args?: { data?: any }) => { + const dataProvider = useDataProvider(); + return useMutationWithMutationMode< + Error, + { data: any }, + { data?: any } + >(args, { + mutationFn: ({ data }) => { + if (!data) { + throw new Error('data is required'); + } + return dataProvider + .updateUserProfile({ data }) + .then(({ data }) => data); + }, + updateCache: ({ data }) => { + return data; + }, + getSnapshot: () => { + return []; + }, + }); + }; + + it('returns a callback that can be used with update arguments', async () => { + const dataProvider = testDataProvider({ + updateUserProfile: jest.fn(({ data }) => + Promise.resolve({ data: { id: 1, ...data } } as any) + ), + }) as MyDataProvider; + let localUpdate; + const Dummy = () => { + const [update] = useUpdateUserProfile(); + localUpdate = update; + return ; + }; + + render( + + + + ); + localUpdate({ + data: { bar: 'baz' }, + }); + await waitFor(() => { + expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({ + data: { bar: 'baz' }, + }); + }); + }); + + it('returns a callback that can be used with no arguments', async () => { + const dataProvider = testDataProvider({ + updateUserProfile: jest.fn(({ data }) => + Promise.resolve({ data: { id: 1, ...data } } as any) + ), + }) as MyDataProvider; + let localUpdate; + const Dummy = () => { + const [update] = useUpdateUserProfile({ + data: { bar: 'baz' }, + }); + localUpdate = update; + return ; + }; + + render( + + + + ); + localUpdate(); + await waitFor(() => { + expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({ + data: { bar: 'baz' }, + }); + }); + }); +}); diff --git a/packages/ra-core/src/dataProvider/useMutationWithMutationMode.ts b/packages/ra-core/src/dataProvider/useMutationWithMutationMode.ts new file mode 100644 index 00000000000..fa0425ef187 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useMutationWithMutationMode.ts @@ -0,0 +1,395 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { + useMutation, + useQueryClient, + UseMutationOptions, + UseMutationResult, + MutateOptions, + QueryKey, +} from '@tanstack/react-query'; + +import { useAddUndoableMutation } from './undo/useAddUndoableMutation'; +import { MutationMode } from '../types'; +import { useEvent } from '../util'; + +export const useMutationWithMutationMode = < + ErrorType = Error, + TData = unknown, + TVariables = unknown, +>( + params: TVariables = {} as TVariables, + options: UseMutationWithMutationModeOptions +): UseMutationWithMutationModeResult => { + const queryClient = useQueryClient(); + const addUndoableMutation = useAddUndoableMutation(); + const { + mutationKey, + mutationMode = 'pessimistic', + mutationFn, + getMutateWithMiddlewares, + updateCache, + getSnapshot, + onUndo, + ...mutationOptions + } = options; + + if (mutationFn == null) { + throw new Error( + 'useMutationWithMutationMode mutation requires a mutationFn' + ); + } + + const mutationFnEvent = useEvent(mutationFn); + const updateCacheEvent = useEvent(updateCache); + const getSnapshotEvent = useEvent(getSnapshot); + const onUndoEvent = useEvent(onUndo ?? noop); + const getMutateWithMiddlewaresEvent = useEvent( + getMutateWithMiddlewares ?? + (noop as unknown as ( + mutate: MutationFunction + ) => (params: TVariables) => Promise) + ); + + const mode = useRef(mutationMode); + useEffect(() => { + mode.current = mutationMode; + }, [mutationMode]); + + const paramsRef = useRef>(params); + useEffect(() => { + paramsRef.current = params; + }, [params]); + + // Ref that stores the snapshot of the state before the mutation to allow reverting it + const snapshot = useRef([]); + // Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted + const mutateWithMiddlewares = useRef< + | MutationFunction + | DataProviderMutationWithMiddlewareFunction + >(mutationFnEvent); + // 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< + UseMutationWithMutationModeOptions< + ErrorType, + TData, + TVariables + >['onError'] + >(); + const callTimeOnSettled = + useRef< + UseMutationWithMutationModeOptions< + ErrorType, + TData, + TVariables + >['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 mutation = useMutation>({ + mutationKey, + mutationFn: async params => { + const callTimeParams = { ...paramsRef.current, ...params }; + if (params == null) { + throw new Error( + 'useMutationWithMutationMode mutation requires parameters' + ); + } + + return mutateWithMiddlewares.current(callTimeParams as TVariables); + }, + ...mutationOptions, + onMutate: async variables => { + 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, variables = {}, context) => { + if (mode.current === 'pessimistic') { + // update the getOne and getList query cache with the new result + updateCacheEvent( + { ...paramsRef.current, ...variables }, + { + mutationMode: mode.current, + }, + data + ); + + if ( + mutationOptions.onSuccess && + !hasCallTimeOnSuccess.current + ) { + mutationOptions.onSuccess(data, variables, context); + } + } + }, + onSettled: ( + data, + error, + variables = {}, + context: { snapshot: Snapshot } + ) => { + // 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 mutate = async ( + callTimeParams: Partial = {}, + callTimeOptions: MutateOptions< + TData, + ErrorType, + Partial, + unknown + > & { mutationMode?: MutationMode; returnPromise?: boolean } = {} + ) => { + const { + mutationMode, + returnPromise = mutationOptions.returnPromise, + onError, + onSettled, + onSuccess, + ...otherCallTimeOptions + } = callTimeOptions; + + // Store the mutation with middlewares to avoid losing them if the calling component is unmounted + if (getMutateWithMiddlewares) { + mutateWithMiddlewares.current = getMutateWithMiddlewaresEvent( + (params: TVariables) => { + // Store the final parameters which might have been changed by middlewares + paramsRef.current = params; + return mutationFnEvent(params); + } + ); + } else { + mutateWithMiddlewares.current = mutationFnEvent; + } + + // 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 (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' + ); + } + + snapshot.current = getSnapshotEvent( + { ...paramsRef.current, ...callTimeParams }, + { + mutationMode: mode.current, + } + ); + + if (mode.current === 'pessimistic') { + if (returnPromise) { + return mutation.mutateAsync( + { ...paramsRef.current, ...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( + { ...paramsRef.current, ...callTimeParams }, + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + { onSuccess, ...otherCallTimeOptions } + ); + } + + // 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 + const optimisticResult = updateCacheEvent( + { ...paramsRef.current, ...callTimeParams }, + { + mutationMode: mode.current, + }, + undefined + ); + + // run the success callbacks during the next tick + setTimeout(() => { + if (onSuccess) { + onSuccess(optimisticResult, callTimeParams, { + snapshot: snapshot.current, + }); + } else if ( + mutationOptions.onSuccess && + !hasCallTimeOnSuccess.current + ) { + mutationOptions.onSuccess(optimisticResult, callTimeParams, { + snapshot: snapshot.current, + }); + } + }, 0); + + if (mode.current === 'optimistic') { + // call the mutate method without success side effects + return mutation.mutate(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) { + if (onUndo) { + onUndoEvent(callTimeParams, { + mutationMode: mode.current, + }); + } + // rollback + snapshot.current.forEach(([key, value]) => { + queryClient.setQueryData(key, value); + }); + } else { + // call the mutate method without success side effects + mutation.mutate(callTimeParams); + } + }); + } + }; + + const mutationResult = useMemo( + () => ({ + isLoading: mutation.isPending, + ...mutation, + }), + [mutation] + ); + + return [useEvent(mutate), mutationResult]; +}; + +const noop = () => {}; + +export type Snapshot = [key: QueryKey, value: any][]; + +type MutationFunction = ( + variables: TVariables +) => Promise; + +export type UseMutationWithMutationModeOptions< + ErrorType = Error, + TData = unknown, + TVariables = unknown, +> = Omit< + UseMutationOptions>, + 'mutationFn' +> & { + getMutateWithMiddlewares?: ( + mutate: MutationFunction + ) => (params: TVariables) => Promise; + mutationFn?: MutationFunction; + mutationMode?: MutationMode; + returnPromise?: boolean; + updateCache: ( + params: Partial, + options: OptionsType, + mutationResult: TData | undefined + ) => TData; + getSnapshot: ( + params: Partial, + options: OptionsType + ) => Snapshot; + onUndo?: ( + params: Partial, + options: OptionsType + ) => void; +}; + +type DataProviderMutationWithMiddlewareFunction< + TData = unknown, + TVariables = unknown, +> = (params: Partial, options?: any) => Promise; + +export type MutationFunctionWithOptions< + TReturnPromise extends boolean = boolean, + ErrorType = Error, + TData = unknown, + TVariables = unknown, +> = ( + params?: Partial, + options?: MutateOptions, unknown> & { + mutationMode?: MutationMode; + returnPromise?: TReturnPromise; + } +) => Promise; + +export type UseMutationWithMutationModeResult< + TReturnPromise extends boolean = boolean, + ErrorType = Error, + TData = unknown, + TVariables = unknown, +> = [ + MutationFunctionWithOptions, + UseMutationResult, unknown> & { + isLoading: boolean; + }, +]; diff --git a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx index 72063daf2a9..919f65be516 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx @@ -349,6 +349,7 @@ describe('useUpdate', () => { }); it('when optimistic, displays result and success side effects right away', async () => { render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -364,6 +365,7 @@ describe('useUpdate', () => { it('when optimistic, displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -388,6 +390,7 @@ describe('useUpdate', () => { }); it('when undoable, displays result and success side effects right away and fetched on confirm', async () => { render(); + await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); @@ -969,6 +972,7 @@ describe('useUpdate', () => { describe('middlewares', () => { it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -989,6 +993,7 @@ describe('useUpdate', () => { it('when pessimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -1012,6 +1017,7 @@ describe('useUpdate', () => { it('when optimistic, it accepts middlewares and displays result and success side effects right away', async () => { render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -1030,6 +1036,7 @@ describe('useUpdate', () => { it('when optimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -1051,6 +1058,7 @@ describe('useUpdate', () => { it('when undoable, it accepts middlewares and displays result and success side effects right away and fetched on confirm', async () => { render(); + await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 1e7388fe53f..c81a2672a44 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -1,18 +1,14 @@ -import { useEffect, useMemo, useRef } from 'react'; import { - useMutation, useQueryClient, + type UseMutationResult, + type MutateOptions, + type UseInfiniteQueryResult, + type InfiniteData, UseMutationOptions, - UseMutationResult, - MutateOptions, - QueryKey, - UseInfiniteQueryResult, - InfiniteData, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { useAddUndoableMutation } from './undo/useAddUndoableMutation'; -import { +import type { RaRecord, UpdateParams, MutationMode, @@ -20,6 +16,10 @@ import { GetInfiniteListResult, DataProvider, } from '../types'; +import { + type Snapshot, + useMutationWithMutationMode, +} from './useMutationWithMutationMode'; import { useEvent } from '../util'; /** @@ -91,431 +91,222 @@ export const useUpdate = ( ): UseUpdateResult => { const dataProvider = useDataProvider(); const queryClient = useQueryClient(); - const addUndoableMutation = useAddUndoableMutation(); - const { id, data, meta } = params; const { mutationMode = 'pessimistic', getMutateWithMiddlewares, ...mutationOptions } = options; - const mode = useRef(mutationMode); - useEffect(() => { - mode.current = mutationMode; - }, [mutationMode]); - - const paramsRef = useRef>>(params); - useEffect(() => { - paramsRef.current = params; - }, [params]); - - const snapshot = useRef([]); - // Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted - const mutateWithMiddlewares = useRef(dataProvider.update); - // 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['onError']>(); - const callTimeOnSettled = - useRef['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 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; - const index = old.findIndex( - // eslint-disable-next-line eqeqeq - record => record.id == id - ); - if (index === -1) { - return old; - } - return [ - ...old.slice(0, index), - { ...old[index], ...clonedData } as RecordType, - ...old.slice(index + 1), - ]; - }; - - 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 dataProviderUpdate = useEvent( + (resource: string, params: UpdateParams) => + dataProvider + .update(resource, params) + .then(({ data }) => data) + ); - const mutation = useMutation< - RecordType, + const [mutate, mutationResult] = useMutationWithMutationMode< ErrorType, - Partial> - >({ - mutationKey: [resource, 'update', params], - mutationFn: ({ - resource: callTimeResource = resource, - id: callTimeId = paramsRef.current.id, - data: callTimeData = paramsRef.current.data, - meta: callTimeMeta = paramsRef.current.meta, - previousData: callTimePreviousData = paramsRef.current.previousData, - } = {}) => { - if (!callTimeResource) { - throw new Error( - 'useUpdate mutation requires a non-empty resource' + RecordType, + UseUpdateMutateParams + >( + { resource, ...params }, + { + ...mutationOptions, + mutationKey: [resource, 'update', params], + mutationMode, + mutationFn: ({ resource, ...params }) => { + if (resource == null) { + throw new Error('useUpdate mutation requires a resource'); + } + if (params == null) { + throw new Error('useUpdate mutation requires parameters'); + } + return dataProviderUpdate( + resource, + params as UpdateParams ); - } - if (callTimeId == null) { - throw new Error('useUpdate mutation requires a non-empty id'); - } - if (!callTimeData) { - throw new Error( - 'useUpdate mutation requires a non-empty data object' + }, + updateCache: ( + { resource, ...params }, + { mutationMode }, + result + ) => { + // 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 = + mutationMode === '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( + mutationMode === 'pessimistic' ? result : params?.data + ) ); - } - return mutateWithMiddlewares - .current(callTimeResource, { - id: callTimeId, - data: callTimeData, - previousData: callTimePreviousData, - meta: callTimeMeta, - }) - .then(({ data }) => data); - }, - ...mutationOptions, - onMutate: async ( - variables: Partial> - ) => { - if (mutationOptions.onMutate) { - const userContext = - (await mutationOptions.onMutate(variables)) || {}; - return { - snapshot: snapshot.current, - // @ts-ignore - ...userContext, + const updateColl = (old: RecordType[]) => { + if (!old) return old; + const index = old.findIndex( + // eslint-disable-next-line eqeqeq + record => record.id == params?.id + ); + if (index === -1) { + return old; + } + return [ + ...old.slice(0, index), + { ...old[index], ...clonedData } as RecordType, + ...old.slice(index + 1), + ]; }; - } 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: RecordType, - variables: Partial> = {}, - context: unknown - ) => { - if (mode.current === 'pessimistic') { - // update the getOne and getList query cache with the new result - const { - resource: callTimeResource = resource, - id: callTimeId = id, - meta: callTimeMeta = meta, - } = variables; - updateCache({ - resource: callTimeResource, - id: callTimeId, - data, - meta: callTimeMeta, - }); - if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess(data, 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 }); - }); - } + type GetListResult = Omit & { + data?: RecordType[]; + }; - if (callTimeOnSettled.current) { - return callTimeOnSettled.current( - data, - error, - variables, - context + const previousRecord = queryClient.getQueryData([ + resource, + 'getOne', + { id: String(params?.id), meta: params?.meta }, + ]); + + queryClient.setQueryData( + [ + resource, + 'getOne', + { id: String(params?.id), meta: params?.meta }, + ], + (record: RecordType) => ({ + ...record, + ...clonedData, + }), + { updatedAt } ); - } - if (mutationOptions.onSettled) { - return mutationOptions.onSettled( - data, - error, - variables, - context + queryClient.setQueriesData( + { queryKey: [resource, 'getList'] }, + (res: GetListResult) => + res && res.data + ? { ...res, data: updateColl(res.data) } + : res, + { updatedAt } ); - } - }, - }); - - const update = async ( - callTimeResource: string | undefined = resource, - callTimeParams: Partial> = {}, - callTimeOptions: MutateOptions< - RecordType, - ErrorType, - Partial>, - unknown - > & { mutationMode?: MutationMode; returnPromise?: boolean } = {} - ) => { - const { - mutationMode, - returnPromise = mutationOptions.returnPromise, - onError, - onSettled, - onSuccess, - ...otherCallTimeOptions - } = callTimeOptions; - - // Store the mutation with middlewares to avoid losing them if the calling component is unmounted - if (getMutateWithMiddlewares) { - mutateWithMiddlewares.current = getMutateWithMiddlewares( - dataProvider.update.bind(dataProvider) - ); - } else { - mutateWithMiddlewares.current = dataProvider.update; - } - - // 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 (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 } + 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 } ); - } - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects - { onSuccess, ...otherCallTimeOptions } - ); - } - - const { - id: callTimeId = id, - data: callTimeData = data, - meta: callTimeMeta = meta, - } = callTimeParams; - - // optimistic update 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 previousRecord = queryClient.getQueryData([ - callTimeResource, - 'getOne', - { id: String(callTimeId), meta: callTimeMeta }, - ]); - - 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( - { ...previousRecord, ...callTimeData } as RecordType, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } + queryClient.setQueriesData( + { queryKey: [resource, 'getMany'] }, + (coll: RecordType[]) => + coll && coll.length > 0 ? updateColl(coll) : coll, + { updatedAt } ); - } else if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess( - { ...previousRecord, ...callTimeData } as RecordType, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } + queryClient.setQueriesData( + { queryKey: [resource, 'getManyReference'] }, + (res: GetListResult) => + res && res.data + ? { + data: updateColl(res.data), + total: res.total, + } + : res, + { updatedAt } ); - } - }, 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 - snapshot.current.forEach(([key, value]) => { - queryClient.setQueryData(key, value); - }); - } else { - // call the mutate method without success side effects - mutation.mutate({ - resource: callTimeResource, - ...callTimeParams, - }); + const optimisticResult = { + ...previousRecord, + ...clonedData, + }; + return optimisticResult; + }, + getSnapshot: ({ resource, ...params }) => { + /** + * Snapshot the previous values via queryClient.getQueriesData() + * + * The snapshotData ref will contain an array of tuples [query key, associated data] + * + * @example + * [ + * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], + * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], + * ] + * + * @see https://tanstack.com/query/v5/docs/react/reference/QueryClient#queryclientgetqueriesdata + */ + const queryKeys = [ + [ + resource, + 'getOne', + { id: String(params?.id), meta: params?.meta }, + ], + [resource, 'getList'], + [resource, 'getInfiniteList'], + [resource, 'getMany'], + [resource, 'getManyReference'], + ]; + + const snapshot = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot + ); + return snapshot; + }, + getMutateWithMiddlewares: mutationFn => args => { + // This is necessary to avoid breaking changes in useUpdate: + // The mutation function must have the same signature as before (resource, params) and not ({ resource, params }) + if (getMutateWithMiddlewares) { + const { resource, ...params } = args; + return getMutateWithMiddlewares( + dataProviderUpdate.bind(dataProvider) + )(resource, params); } - }); + return mutationFn(args); + }, } - }; + ); - const mutationResult = useMemo( - () => ({ - isLoading: mutation.isPending, - ...mutation, - }), - [mutation] + const update = useEvent( + ( + callTimeResource: string | undefined = resource, + callTimeParams: Partial> = {}, + callTimeOptions: MutateOptions< + RecordType, + ErrorType, + Partial>, + unknown + > & { + mutationMode?: MutationMode; + returnPromise?: boolean; + } = {} + ) => { + return mutate( + { + resource: callTimeResource, + ...callTimeParams, + }, + callTimeOptions + ); + } ); - return [useEvent(update), mutationResult]; + return [update, mutationResult]; }; -type Snapshot = [key: QueryKey, value: any][]; - export interface UseUpdateMutateParams { resource?: string; id?: RecordType['id']; diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.spec.tsx b/packages/ra-core/src/dataProvider/useUpdateMany.spec.tsx index 800c86d6244..0b8f040d0b0 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.spec.tsx +++ b/packages/ra-core/src/dataProvider/useUpdateMany.spec.tsx @@ -530,6 +530,7 @@ describe('useUpdateMany', () => { describe('middlewares', () => { it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -538,18 +539,6 @@ describe('useUpdateMany', () => { ).toBeNull(); expect(screen.queryByText('mutating')).not.toBeNull(); }); - await waitFor(() => { - expect(screen.queryByText('success')).not.toBeNull(); - expect( - // We could expect 'Hello World from middleware' here, but - // updateMany's result only contains the ids, not the updated data - // so the cache can only be updated with the call-time params, - // which do not include the middleware's result. - // I guess it's OK for most cases though... - screen.queryByText('Hello World') - ).not.toBeNull(); - expect(screen.queryByText('mutating')).toBeNull(); - }); screen.getByText('Refetch').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -569,6 +558,7 @@ describe('useUpdateMany', () => { timeout={10} /> ); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); @@ -592,6 +582,7 @@ describe('useUpdateMany', () => { it('when optimistic, it accepts middlewares and displays result and success side effects right away', async () => { render(); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -616,6 +607,7 @@ describe('useUpdateMany', () => { timeout={10} /> ); + await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); @@ -637,6 +629,7 @@ describe('useUpdateMany', () => { it('when undoable, it accepts middlewares and displays result and success side effects right away and fetched on confirm', async () => { render(); + await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); @@ -667,6 +660,7 @@ describe('useUpdateMany', () => { it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on cancel', async () => { render(); await screen.findByText('Hello'); + await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts index 0051e37fbc0..f8e43231c70 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -1,18 +1,15 @@ -import { useEffect, useMemo, useRef } from 'react'; import { - useMutation, useQueryClient, - UseMutationOptions, - UseMutationResult, - MutateOptions, - QueryKey, - UseInfiniteQueryResult, - InfiniteData, + type UseMutationOptions, + type UseMutationResult, + type MutateOptions, + type UseInfiniteQueryResult, + type InfiniteData, } from '@tanstack/react-query'; import { useDataProvider } from './useDataProvider'; -import { useAddUndoableMutation } from './undo/useAddUndoableMutation'; -import { +import type { + Identifier, RaRecord, UpdateManyParams, MutationMode, @@ -20,8 +17,11 @@ import { GetInfiniteListResult, DataProvider, } from '../types'; +import { + type Snapshot, + useMutationWithMutationMode, +} from './useMutationWithMutationMode'; import { useEvent } from '../util'; -import { Identifier } from '..'; /** * Get a callback to call the dataProvider.updateMany() method, the result and the loading state. @@ -88,436 +88,210 @@ export const useUpdateMany = < ): UseUpdateManyResult => { const dataProvider = useDataProvider(); const queryClient = useQueryClient(); - const addUndoableMutation = useAddUndoableMutation(); - const { ids, data, meta } = params; const { mutationMode = 'pessimistic', getMutateWithMiddlewares, ...mutationOptions } = options; - const mode = useRef(mutationMode); - useEffect(() => { - mode.current = mutationMode; - }, [mutationMode]); - - const paramsRef = - useRef>>>(params); - useEffect(() => { - paramsRef.current = params; - }, [params]); - - const snapshot = useRef([]); - // Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted - const mutateWithMiddlewares = useRef(dataProvider.updateMany); - const hasCallTimeOnError = useRef(false); - const hasCallTimeOnSuccess = useRef(false); - const hasCallTimeOnSettled = useRef(false); - - const updateCache = async ({ - resource, - ids, - data, - meta, - }: { - resource: string; - ids: Identifier[]; - data: any; - meta?: any; - }) => { - // 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 updatedAt = - mode.current === 'undoable' ? Date.now() + 1000 * 5 : Date.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; - let newCollection = [...old]; - ids.forEach(id => { - // eslint-disable-next-line eqeqeq - const index = old.findIndex(record => record.id == id); - if (index === -1) { - return; - } - newCollection = [ - ...newCollection.slice(0, index), - { ...newCollection[index], ...clonedData }, - ...newCollection.slice(index + 1), - ]; - }); - return newCollection; - }; - - type GetListResult = Omit & { - data?: RecordType[]; - }; - - ids.forEach(id => { - 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 dataProviderUpdateMany = useEvent( + (resource: string, params: UpdateManyParams) => + dataProvider + .updateMany(resource, params) + .then(({ data }) => data) + ); - const mutation = useMutation< - Array, + const [mutate, mutationResult] = useMutationWithMutationMode< MutationError, - Partial> - >({ - mutationKey: [resource, 'updateMany', params], - mutationFn: ({ - resource: callTimeResource = resource, - ids: callTimeIds = paramsRef.current.ids, - data: callTimeData = paramsRef.current.data, - meta: callTimeMeta = paramsRef.current.meta, - } = {}) => { - if (!callTimeResource) { - throw new Error( - 'useUpdateMany mutation requires a non-empty resource' - ); - } - if (!callTimeIds) { - throw new Error( - 'useUpdateMany mutation requires an array of ids' - ); - } - if (!callTimeData) { - throw new Error( - 'useUpdateMany mutation requires a non-empty data object' - ); - } - return mutateWithMiddlewares - .current(callTimeResource, { - ids: callTimeIds, - data: callTimeData, - meta: callTimeMeta, - }) - .then(({ data }) => data || []); - }, - ...mutationOptions, - 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: MutationError, - variables: Partial> = {}, - 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 (mutationOptions.onError && !hasCallTimeOnError.current) { - return mutationOptions.onError(error, variables, context); - } - // call-time error callback is executed by react-query - }, - onSuccess: ( - dataFromResponse: Array, - variables: Partial> = {}, - context: unknown - ) => { - if (mode.current === 'pessimistic') { - // update the getOne and getList query cache with the new result - const { - resource: callTimeResource = resource, - ids: callTimeIds = ids, - data: callTimeData = data, - meta: callTimeMeta = meta, - } = variables; - if (!callTimeResource) { + Array | undefined, + UseUpdateManyMutateParams + >( + { resource, ...params }, + { + ...mutationOptions, + mutationKey: [resource, 'updateMany', params], + mutationMode, + mutationFn: ({ resource, ...params }) => { + if (resource == null) { throw new Error( - 'useUpdateMany mutation requires a non-empty resource' + 'useUpdateMany mutation requires a resource' ); } - if (!callTimeIds) { + if (params == null) { throw new Error( - 'useUpdateMany mutation requires an array of ids' + 'useUpdateMany mutation requires parameters' ); } - updateCache({ - resource: callTimeResource, - ids: callTimeIds, - data: callTimeData, - meta: callTimeMeta, - }); + return dataProviderUpdateMany( + resource, + params as UpdateManyParams + ); + }, + updateCache: ({ resource, ...params }, { mutationMode }) => { + // 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 updatedAt = + mutationMode === 'undoable' + ? Date.now() + 1000 * 5 + : Date.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 = params?.data + ? JSON.parse(JSON.stringify(params?.data)) + : undefined; + + const updateColl = (old: RecordType[]) => { + if (!old) return old; + let newCollection = [...old]; + (params?.ids ?? []).forEach(id => { + // eslint-disable-next-line eqeqeq + const index = old.findIndex(record => record.id == id); + if (index === -1) { + return; + } + newCollection = [ + ...newCollection.slice(0, index), + { ...newCollection[index], ...clonedData }, + ...newCollection.slice(index + 1), + ]; + }); + return newCollection; + }; - if ( - mutationOptions.onSuccess && - !hasCallTimeOnSuccess.current - ) { - mutationOptions.onSuccess( - dataFromResponse, - variables, - context + type GetListResult = Omit & { + data?: RecordType[]; + }; + + (params?.ids ?? []).forEach(id => { + queryClient.setQueryData( + [ + resource, + 'getOne', + { id: String(id), meta: params?.meta }, + ], + (record: RecordType) => ({ + ...record, + ...clonedData, + }), + { updatedAt } ); - } - } - }, - onSettled: ( - data: Array, - error: MutationError, - variables: Partial> = {}, - context: { snapshot: Snapshot } - ) => { - if (mode.current === 'optimistic' || mode.current === 'undoable') { - // Always refetch after error or success: - context.snapshot.forEach(([queryKey]) => { - queryClient.invalidateQueries({ queryKey }); }); - } - - if (mutationOptions.onSettled && !hasCallTimeOnSettled.current) { - return mutationOptions.onSettled( - data, - error, - variables, - context + queryClient.setQueriesData( + { queryKey: [resource, 'getList'] }, + (res: GetListResult) => + res && res.data + ? { ...res, data: updateColl(res.data) } + : res, + { updatedAt } ); - } - }, - }); - - const updateMany = async ( - callTimeResource: string | undefined = resource, - callTimeParams: Partial> = {}, - callTimeOptions: MutateOptions< - Array, - unknown, - Partial>, - unknown - > & { mutationMode?: MutationMode; returnPromise?: boolean } = {} - ) => { - if (!callTimeResource) { - throw new Error( - 'useUpdateMany mutation requires a non-empty resource' - ); - } - const { - mutationMode, - returnPromise = mutationOptions.returnPromise, - ...otherCallTimeOptions - } = callTimeOptions; - - // Store the mutation with middlewares to avoid losing them if the calling component is unmounted - if (getMutateWithMiddlewares) { - mutateWithMiddlewares.current = getMutateWithMiddlewares( - dataProvider.updateMany.bind(dataProvider) - ); - } else { - mutateWithMiddlewares.current = dataProvider.updateMany; - } - - hasCallTimeOnError.current = !!otherCallTimeOptions.onError; - hasCallTimeOnSuccess.current = !!otherCallTimeOptions.onSuccess; - hasCallTimeOnSettled.current = !!otherCallTimeOptions.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 (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 }, - otherCallTimeOptions + 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 } ); - } - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions - ); - } - - const { - ids: callTimeIds = ids, - data: callTimeData = data, - meta: callTimeMeta = meta, - } = callTimeParams; - if (!callTimeIds) { - throw new Error('useUpdateMany mutation requires an array of ids'); - } - - // optimistic update as documented in https://react-query-v5.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'], - [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://tanstack.com/query/v5/docs/react/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 data - await updateCache({ - resource: callTimeResource, - ids: callTimeIds, - data: callTimeData, - meta: callTimeMeta, - }); + return params?.ids as Identifier[]; + }, + getSnapshot: ({ resource }) => { + /** + * Snapshot the previous values via queryClient.getQueriesData() + * + * The snapshotData ref will contain an array of tuples [query key, associated data] + * + * @example + * [ + * [['posts', 'getList'], { data: [{ id: 1, title: 'Hello' }], total: 1 }], + * [['posts', 'getMany'], [{ id: 1, title: 'Hello' }]], + * ] + * + * @see https://tanstack.com/query/v5/docs/react/reference/QueryClient#queryclientgetqueriesdata + */ + const queryKeys = [ + [resource, 'getOne'], + [resource, 'getList'], + [resource, 'getInfiniteList'], + [resource, 'getMany'], + [resource, 'getManyReference'], + ]; - // run the success callbacks during the next tick - setTimeout(() => { - if (otherCallTimeOptions.onSuccess) { - otherCallTimeOptions.onSuccess( - callTimeIds, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } + const snapshot = queryKeys.reduce( + (prev, queryKey) => + prev.concat(queryClient.getQueriesData({ queryKey })), + [] as Snapshot ); - } else if (mutationOptions.onSuccess) { - mutationOptions.onSuccess( - callTimeIds, - { resource: callTimeResource, ...callTimeParams }, - { snapshot: snapshot.current } - ); - } - }, 0); + return snapshot; + }, + getMutateWithMiddlewares: mutationFn => args => { + // This is necessary to avoid breaking changes in useUpdateMany: + // The mutation function must have the same signature as before (resource, params) and not ({ resource, params }) + if (getMutateWithMiddlewares) { + const { resource, ...params } = args; + return getMutateWithMiddlewares( + dataProviderUpdateMany.bind(dataProvider) + )(resource, params); + } + return mutationFn(args); + }, + } + ); - if (mode.current === 'optimistic') { - // call the mutate method without success side effects - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, + const updateMany = useEvent( + ( + callTimeResource: string | undefined = resource, + callTimeParams: Partial> = {}, + callTimeOptions: MutateOptions< + Array | undefined, + MutationError, + Partial>, + unknown + > & { + mutationMode?: MutationMode; + returnPromise?: boolean; + } = {} + ) => { + return mutate( { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } + resource: callTimeResource, + ...callTimeParams, + }, + callTimeOptions ); - } 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 - snapshot.current.forEach(([key, value]) => { - queryClient.setQueryData(key, value); - }); - } else { - // call the mutate method without success side effects - mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } - ); - } - }); } - }; - - const mutationResult = useMemo( - () => ({ - isLoading: mutation.isPending, - ...mutation, - }), - [mutation] ); - - return [useEvent(updateMany), mutationResult]; + return [updateMany, mutationResult]; }; -type Snapshot = [key: QueryKey, value: any][]; - export interface UseUpdateManyMutateParams { resource?: string; ids?: Array; @@ -562,7 +336,7 @@ export type UseUpdateManyResult< > & { mutationMode?: MutationMode; returnPromise?: TReturnPromise } ) => Promise : void>, UseMutationResult< - Array, + Array | undefined, MutationError, Partial> & { resource?: string }>, unknown diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx index 410cff37d48..e69f7ebd124 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx @@ -177,7 +177,7 @@ describe('', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); @@ -230,7 +230,7 @@ describe('', () => { resource: 'posts', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx index 9d92fffa0d4..d88dbb73a90 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx @@ -402,7 +402,7 @@ describe('', () => { resource: 'foo', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); @@ -467,7 +467,7 @@ describe('', () => { resource: 'foo', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); expect(onSuccess).not.toHaveBeenCalled(); }); @@ -526,7 +526,7 @@ describe('', () => { resource: 'foo', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); }); }); @@ -591,7 +591,7 @@ describe('', () => { resource: 'foo', meta: undefined, }, - { snapshot: [] } + { snapshot: expect.any(Array) } ); expect(onError).not.toHaveBeenCalled(); });