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();
});