diff --git a/docs/Upgrade.md b/docs/Upgrade.md index de7303709fa..5cf921dcf06 100644 --- a/docs/Upgrade.md +++ b/docs/Upgrade.md @@ -1310,6 +1310,40 @@ The `data-generator-retail` package has been updated to provide types for all it React-admin no longer supports ([deprecated React PropTypes](https://legacy.reactjs.org/blog/2017/04/07/react-v15.5.0.html#new-deprecation-warnings)). We encourage you to switch to TypeScript to type component props. +### Mutation Middlewares No Longer Receive The Mutation Options + +Mutations middlewares no longer receive the mutation options: + +```diff +import * as React from 'react'; +import { + useRegisterMutationMiddleware, + CreateParams, +- MutateOptions, + CreateMutationFunction +} from 'react-admin'; + +const MyComponent = () => { + const createMiddleware = async ( + resource: string, + params: CreateParams, +- options: MutateOptions, + next: CreateMutationFunction + ) => { + // Do something before the mutation + + // Call the next middleware +- await next(resource, params, options); ++ await next(resource, params); + + // Do something after the mutation + } + const memoizedMiddleWare = React.useCallback(createMiddleware, []); + useRegisterMutationMiddleware(memoizedMiddleWare); + // ... +} +``` + ## Upgrading to v4 If you are on react-admin v3, follow the [Upgrading to v4](https://marmelab.com/react-admin/doc/4.16/Upgrade.html) guide before upgrading to v5. diff --git a/docs/useRegisterMutationMiddleware.md b/docs/useRegisterMutationMiddleware.md index 05748392a5b..7f23c58380c 100644 --- a/docs/useRegisterMutationMiddleware.md +++ b/docs/useRegisterMutationMiddleware.md @@ -28,7 +28,6 @@ import * as React from 'react'; import { useRegisterMutationMiddleware, CreateParams, - MutateOptions, CreateMutationFunction } from 'react-admin'; @@ -36,13 +35,12 @@ const MyComponent = () => { const createMiddleware = async ( resource: string, params: CreateParams, - options: MutateOptions, next: CreateMutationFunction ) => { // Do something before the mutation // Call the next middleware - await next(resource, params, options); + await next(resource, params); // Do something after the mutation } @@ -65,11 +63,11 @@ React-admin will wrap each call to the `dataProvider.create()` mutation with the A middleware function must have the following signature: ```jsx -const middlware = async (resource, params, options, next) => { +const middlware = async (resource, params, next) => { // Do something before the mutation // Call the next middleware - await next(resource, params, options); + await next(resource, params); // Do something after the mutation } @@ -97,13 +95,12 @@ const ThumbnailInput = () => { const middleware = useCallback(async ( resource, params, - options, next ) => { const b64 = await convertFileToBase64(params.data.thumbnail); // Update the parameters that will be sent to the dataProvider call const newParams = { ...params, data: { ...data, thumbnail: b64 } }; - await next(resource, newParams, options); + await next(resource, newParams); }, []); useRegisterMutationMiddleware(middleware); diff --git a/packages/ra-core/src/controller/create/CreateController.tsx b/packages/ra-core/src/controller/create/CreateController.tsx index 9f70cf033e6..38ce92efeb7 100644 --- a/packages/ra-core/src/controller/create/CreateController.tsx +++ b/packages/ra-core/src/controller/create/CreateController.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { useCreateController, CreateControllerProps, @@ -21,7 +22,7 @@ export const CreateController = ({ children, ...props }: { - children: (params: CreateControllerResult) => JSX.Element; + children: (params: CreateControllerResult) => ReactNode; } & CreateControllerProps) => { const controllerProps = useCreateController(props); return children(controllerProps); diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index a300c996f70..f018f76ec5d 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -18,7 +18,7 @@ import { useInput, } from '../..'; import { CoreAdminContext } from '../../core'; -import { testDataProvider, useCreate } from '../../dataProvider'; +import { testDataProvider } from '../../dataProvider'; import { useNotificationContext } from '../../notification'; import { Middleware, @@ -35,9 +35,9 @@ describe('useCreateController', () => { const location: Location = { key: 'a_key', pathname: '/foo', - search: undefined, + search: '', state: undefined, - hash: undefined, + hash: '', }; it('should return location state record when set', () => { @@ -563,20 +563,17 @@ describe('useCreateController', () => { const dataProvider = testDataProvider({ create, }); - const middleware: Middleware[0]> = jest.fn( - (resource, params, options, next) => { - return next( - resource, - { ...params, meta: { addedByMiddleware: true } }, - options - ); + const middleware: Middleware = jest.fn( + (resource, params, next) => { + return next(resource, { + ...params, + meta: { addedByMiddleware: true }, + }); } ); const Child = () => { - useRegisterMutationMiddleware[0]>( - middleware - ); + useRegisterMutationMiddleware(middleware); return null; }; render( @@ -616,7 +613,6 @@ describe('useCreateController', () => { { data: { foo: 'bar' }, }, - expect.any(Object), expect.any(Function) ); }); diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index c00027bf3f9..a3b06d3ea58 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -84,7 +84,7 @@ export const useCreateController = < } = mutationOptions; const { registerMutationMiddleware, - getMutateWithMiddlewares, + mutateWithMiddlewares, unregisterMutationMiddleware, } = useMutationMiddlewares(); @@ -139,6 +139,7 @@ export const useCreateController = < }, ...otherMutationOptions, returnPromise: true, + mutateWithMiddlewares, }); const save = useCallback( @@ -157,9 +158,8 @@ export const useCreateController = < ? transform(data) : data ).then(async (data: Partial) => { - const mutate = getMutateWithMiddlewares(create); try { - await mutate( + await create( resource, { data, meta: metaFromSave ?? meta }, callTimeOptions @@ -176,7 +176,7 @@ export const useCreateController = < } } }), - [create, getMutateWithMiddlewares, meta, resource, transform] + [create, meta, resource, transform] ); const getResourceLabel = useGetResourceLabel(); diff --git a/packages/ra-core/src/controller/edit/EditController.tsx b/packages/ra-core/src/controller/edit/EditController.tsx index db33d294b60..caa3e468764 100644 --- a/packages/ra-core/src/controller/edit/EditController.tsx +++ b/packages/ra-core/src/controller/edit/EditController.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { useEditController, EditControllerProps, @@ -21,7 +22,7 @@ export const EditController = ({ children, ...props }: { - children: (params: EditControllerResult) => JSX.Element; + children: (params: EditControllerResult) => ReactNode; } & EditControllerProps) => { const controllerProps = useEditController(props); return children(controllerProps); diff --git a/packages/ra-core/src/controller/edit/useEditController.spec.tsx b/packages/ra-core/src/controller/edit/useEditController.spec.tsx index 6342f332d4e..eac1080afcd 100644 --- a/packages/ra-core/src/controller/edit/useEditController.spec.tsx +++ b/packages/ra-core/src/controller/edit/useEditController.spec.tsx @@ -15,14 +15,14 @@ import { useEditController, } from '..'; import { CoreAdminContext } from '../../core'; -import { testDataProvider, useUpdate } from '../../dataProvider'; +import { testDataProvider } from '../../dataProvider'; import undoableEventEmitter from '../../dataProvider/undoableEventEmitter'; import { Form, InputProps, useInput } from '../../form'; import { useNotificationContext } from '../../notification'; import { DataProvider } from '../../types'; import { Middleware, useRegisterMutationMiddleware } from '../saveContext'; import { EditController } from './EditController'; -import { TestMemoryRouter } from '../../routing'; +import { RedirectionSideEffect, TestMemoryRouter } from '../../routing'; describe('useEditController', () => { const defaultProps = { @@ -131,10 +131,14 @@ describe('useEditController', () => { Promise.resolve({ data: { id: 12, title: 'hello' } }) ); const dataProvider = ({ getOne } as unknown) as DataProvider; - const Component = ({ redirect = undefined }) => ( + const Component = ({ + redirect = undefined, + }: { + redirect?: RedirectionSideEffect; + }) => ( - {({ redirect }) =>
{redirect}
} + {({ redirect }) =>
{redirect.toString()}
}
); @@ -224,7 +228,7 @@ describe('useEditController', () => {

{record?.test}

+   + + + {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'optimistic', + mutateWithMiddlewares: async (mutate, resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + setError(undefined); + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {error &&
{error.message}
} + {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useUpdate.pessimistic.stories.tsx b/packages/ra-core/src/dataProvider/useUpdate.pessimistic.stories.tsx index c280806754c..bd2afd70ac9 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.pessimistic.stories.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.pessimistic.stories.tsx @@ -12,13 +12,11 @@ export const SuccessCase = ({ timeout = 1000 }) => { const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; const dataProvider = { getOne: (resource, params) => { - console.log('getOne', resource, params); return Promise.resolve({ data: posts.find(p => p.id === params.id), }); }, update: (resource, params) => { - console.log('update', resource, params); return new Promise(resolve => { setTimeout(() => { const post = posts.find(p => p.id === params.id); @@ -83,13 +81,11 @@ export const ErrorCase = ({ timeout = 1000 }) => { const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; const dataProvider = { getOne: (resource, params) => { - console.log('getOne', resource, params); return Promise.resolve({ data: posts.find(p => p.id === params.id), }); }, - update: (resource, params) => { - console.log('update', resource, params); + update: () => { return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('something went wrong')); @@ -149,3 +145,171 @@ const ErrorCore = () => { ); }; + +export const WithMiddlewaresSuccess = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + const post = posts.find(p => p.id === params.id); + if (post) { + post.title = params.data.title; + } + resolve({ data: post }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresSuccessCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'pessimistic', + mutateWithMiddlewares: async (mutate, resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'pessimistic', + onSuccess: () => setSuccess('success'), + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'pessimistic', + mutateWithMiddlewares: async (mutate, resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + setError(undefined); + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'pessimistic', + onSuccess: () => setSuccess('success'), + onError: e => setError(e), + } + ); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ +   + +
+ {error &&
{error.message}
} + {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; diff --git a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx index 5bb587a84bc..adf20b4df85 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx @@ -8,14 +8,20 @@ import { useUpdate } from './useUpdate'; import { ErrorCase as ErrorCasePessimistic, SuccessCase as SuccessCasePessimistic, + WithMiddlewaresSuccess as WithMiddlewaresSuccessPessimistic, + WithMiddlewaresError as WithMiddlewaresErrorPessimistic, } from './useUpdate.pessimistic.stories'; import { ErrorCase as ErrorCaseOptimistic, SuccessCase as SuccessCaseOptimistic, + WithMiddlewaresSuccess as WithMiddlewaresSuccessOptimistic, + WithMiddlewaresError as WithMiddlewaresErrorOptimistic, } from './useUpdate.optimistic.stories'; import { ErrorCase as ErrorCaseUndoable, SuccessCase as SuccessCaseUndoable, + WithMiddlewaresSuccess as WithMiddlewaresSuccessUndoable, + WithMiddlewaresError as WithMiddlewaresErrorUndoable, } from './useUpdate.undoable.stories'; import { QueryClient } from '@tanstack/react-query'; @@ -188,7 +194,6 @@ describe('useUpdate', () => { describe('mutationMode', () => { it('when pessimistic, displays result and success side effects when dataProvider promise resolves', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); render(); screen.getByText('Update title').click(); await waitFor(() => { @@ -203,7 +208,6 @@ describe('useUpdate', () => { }); }); it('when pessimistic, displays error and error side effects when dataProvider promise rejects', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); render(); screen.getByText('Update title').click(); @@ -223,7 +227,6 @@ describe('useUpdate', () => { }); }); it('when optimistic, displays result and success side effects right away', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); render(); screen.getByText('Update title').click(); await waitFor(() => { @@ -238,7 +241,6 @@ describe('useUpdate', () => { }); }); it('when optimistic, displays error and error side effects when dataProvider promise rejects', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); render(); screen.getByText('Update title').click(); @@ -258,7 +260,6 @@ describe('useUpdate', () => { await screen.findByText('Hello'); }); it('when undoable, displays result and success side effects right away and fetched on confirm', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); render(); act(() => { screen.getByText('Update title').click(); @@ -286,7 +287,6 @@ describe('useUpdate', () => { expect(screen.queryByText('Hello World')).not.toBeNull(); }); it('when undoable, displays result and success side effects right away and reverts on cancel', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); render(); await screen.findByText('Hello'); act(() => { @@ -307,7 +307,6 @@ describe('useUpdate', () => { await screen.findByText('Hello'); }); it('when undoable, displays result and success side effects right away and reverts on error', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); render(); await screen.findByText('Hello'); @@ -473,6 +472,164 @@ describe('useUpdate', () => { }); }); }); + describe('middlewares', () => { + it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { + render(); + screen.getByText('Update title').click(); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + + it('when pessimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + screen.getByText('Update title').click(); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect(screen.queryByText('something went wrong')).toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('something went wrong') + ).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + + it('when optimistic, it accepts middlewares and displays result and success side effects right away', async () => { + render(); + screen.getByText('Update title').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + it('when optimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + screen.getByText('Update title').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('something went wrong') + ).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + await screen.findByText('Hello'); + }); + + it('when undoable, it accepts middlewares and displays result and success side effects right away and fetched on confirm', async () => { + render(); + act(() => { + screen.getByText('Update title').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Confirm').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await waitFor( + () => { + expect(screen.queryByText('mutating')).toBeNull(); + }, + { timeout: 4000 } + ); + expect(screen.queryByText('success')).not.toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).not.toBeNull(); + }); + 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'); + act(() => { + screen.getByText('Update title').click(); + }); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + act(() => { + screen.getByText('Cancel').click(); + }); + await waitFor(() => { + expect(screen.queryByText('Hello World')).toBeNull(); + }); + expect(screen.queryByText('mutating')).toBeNull(); + await screen.findByText('Hello'); + }); + it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on error', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await screen.findByText('Hello'); + screen.getByText('Update title').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + screen.getByText('Confirm').click(); + await waitFor(() => { + expect(screen.queryByText('success')).not.toBeNull(); + expect(screen.queryByText('Hello World')).not.toBeNull(); + expect(screen.queryByText('mutating')).not.toBeNull(); + }); + await screen.findByText('Hello', undefined, { timeout: 4000 }); + await waitFor(() => { + expect(screen.queryByText('success')).toBeNull(); + expect( + screen.queryByText('Hello World from middleware') + ).toBeNull(); + expect(screen.queryByText('mutating')).toBeNull(); + }); + }); + }); }); afterEach(() => { diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 5f6a0d3d40e..c8efad5edba 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -18,6 +18,7 @@ import { MutationMode, GetListResult as OriginalGetListResult, GetInfiniteListResult, + DataProvider, } from '../types'; import { useEvent } from '../util'; @@ -91,13 +92,28 @@ export const useUpdate = ( const dataProvider = useDataProvider(); const queryClient = useQueryClient(); const { id, data, meta } = params; - const { mutationMode = 'pessimistic', ...mutationOptions } = options; + const { + mutationMode = 'pessimistic', + mutateWithMiddlewares, + ...mutationOptions + } = options; const mode = useRef(mutationMode); const paramsRef = useRef>>(params); const snapshot = useRef([]); - const hasCallTimeOnError = useRef(false); + // 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< + UseUpdateOptions['onError'] + >(); + const callTimeOnSettled = useRef< + UseUpdateOptions['onSettled'] + >(); + + // We don't need to keep a ref on the onSuccess callback as we call it ourselves for optimistic and + // undoable mutations. There is a limitation though: if one of the side effects applied by the onSuccess callback + // unmounts the component that called the useUpdate hook (redirect for instance), it must be the last one applied, + // otherwise the other side effects may not applied. const hasCallTimeOnSuccess = useRef(false); - const hasCallTimeOnSettled = useRef(false); const updateCache = ({ resource, id, data }) => { // hack: only way to tell react-query not to fetch this query for the next 5 seconds @@ -195,6 +211,18 @@ export const useUpdate = ( 'useUpdate mutation requires a non-empty data object' ); } + if (mutateWithMiddlewares) { + return mutateWithMiddlewares( + dataProvider.update.bind(dataProvider), + callTimeResource, + { + id: callTimeId, + data: callTimeData, + previousData: callTimePreviousData, + meta: callTimeMeta, + } + ).then(({ data }) => data); + } return dataProvider .update(callTimeResource, { id: callTimeId, @@ -229,7 +257,10 @@ export const useUpdate = ( }); } - if (mutationOptions.onError && !hasCallTimeOnError.current) { + 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 @@ -272,7 +303,15 @@ export const useUpdate = ( }); } - if (mutationOptions.onSettled && !hasCallTimeOnSettled.current) { + if (callTimeOnSettled.current) { + return callTimeOnSettled.current( + data, + error, + variables, + context + ); + } + if (mutationOptions.onSettled) { return mutationOptions.onSettled( data, error, @@ -296,12 +335,18 @@ export const useUpdate = ( const { mutationMode, returnPromise = mutationOptions.returnPromise, + onError, + onSettled, + onSuccess, ...otherCallTimeOptions } = callTimeOptions; - hasCallTimeOnError.current = !!otherCallTimeOptions.onError; - hasCallTimeOnSuccess.current = !!otherCallTimeOptions.onSuccess; - hasCallTimeOnSettled.current = !!otherCallTimeOptions.onSettled; + // 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 @@ -322,12 +367,14 @@ export const useUpdate = ( if (returnPromise) { return mutation.mutateAsync( { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + { onSuccess, ...otherCallTimeOptions } ); } return mutation.mutate( { resource: callTimeResource, ...callTimeParams }, - otherCallTimeOptions + // We don't pass onError and onSettled here as we will call them in the useMutation hook side effects + { onSuccess, ...otherCallTimeOptions } ); } @@ -395,8 +442,8 @@ export const useUpdate = ( // run the success callbacks during the next tick setTimeout(() => { - if (otherCallTimeOptions.onSuccess) { - otherCallTimeOptions.onSuccess( + if (onSuccess) { + onSuccess( { ...previousRecord, ...callTimeData } as RecordType, { resource: callTimeResource, ...callTimeParams }, { snapshot: snapshot.current } @@ -415,13 +462,11 @@ export const useUpdate = ( if (mode.current === 'optimistic') { // call the mutate method without success side effects - return mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } - ); + 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: register the mutation for later undoableEventEmitter.once('end', ({ isUndo }) => { @@ -432,13 +477,10 @@ export const useUpdate = ( }); } else { // call the mutate method without success side effects - mutation.mutate( - { resource: callTimeResource, ...callTimeParams }, - { - onSettled: otherCallTimeOptions.onSettled, - onError: otherCallTimeOptions.onError, - } - ); + mutation.mutate({ + resource: callTimeResource, + ...callTimeParams, + }); } }); } @@ -472,7 +514,16 @@ export type UseUpdateOptions< RecordType, ErrorType, Partial, 'mutationFn'>> -> & { mutationMode?: MutationMode; returnPromise?: boolean }; +> & { + mutationMode?: MutationMode; + returnPromise?: boolean; + mutateWithMiddlewares?: < + UpdateFunctionType extends DataProvider['update'] = DataProvider['update'] + >( + mutate: UpdateFunctionType, + ...Params: Parameters + ) => ReturnType; +}; export type UpdateMutationFunction< RecordType extends RaRecord = any, diff --git a/packages/ra-core/src/dataProvider/useUpdate.undoable.stories.tsx b/packages/ra-core/src/dataProvider/useUpdate.undoable.stories.tsx index 5c049061fdd..382c3d4121b 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.undoable.stories.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.undoable.stories.tsx @@ -13,13 +13,11 @@ export const SuccessCase = ({ timeout = 1000 }) => { const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; const dataProvider = { getOne: (resource, params) => { - console.log('getOne', resource, params); return Promise.resolve({ data: posts.find(p => p.id === params.id), }); }, update: (resource, params) => { - console.log('update', resource, params); return new Promise(resolve => { setTimeout(() => { const post = posts.find(p => p.id === params.id); @@ -112,13 +110,11 @@ export const ErrorCase = ({ timeout = 1000 }) => { const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; const dataProvider = { getOne: (resource, params) => { - console.log('getOne', resource, params); return Promise.resolve({ data: posts.find(p => p.id === params.id), }); }, - update: (resource, params) => { - console.log('update', resource, params); + update: () => { return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('something went wrong')); @@ -208,3 +204,227 @@ const ErrorCore = () => { ); }; + +export const WithMiddlewaresSuccess = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: (resource, params) => { + return new Promise(resolve => { + setTimeout(() => { + const post = posts.find(p => p.id === params.id); + if (post) { + post.title = params.data.title; + } + resolve({ data: post }); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + mutateWithMiddlewares: async (mutate, resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + } + ); + setNotification(true); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {isMutating !== 0 &&
mutating
} + + ); +}; + +export const WithMiddlewaresError = ({ timeout = 1000 }) => { + const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }]; + const dataProvider = { + getOne: (resource, params) => { + return Promise.resolve({ + data: posts.find(p => p.id === params.id), + }); + }, + update: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('something went wrong')); + }, timeout); + }); + }, + } as any; + return ( + + + + ); +}; + +const WithMiddlewaresErrorCore = () => { + const isMutating = useIsMutating(); + const [notification, setNotification] = useState(false); + const [success, setSuccess] = useState(); + const [error, setError] = useState(); + const { data, refetch } = useGetOne('posts', { id: 1 }); + const [update, { isPending }] = useUpdate( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + mutationMode: 'undoable', + mutateWithMiddlewares: async (mutate, resource, params) => { + return mutate(resource, { + ...params, + data: { title: `${params.data.title} from middleware` }, + }); + }, + } + ); + const handleClick = () => { + update( + 'posts', + { + id: 1, + data: { title: 'Hello World' }, + }, + { + onSuccess: () => setSuccess('success'), + onError: e => { + setError(e); + setSuccess(''); + }, + } + ); + setNotification(true); + }; + return ( + <> +
+
title
+
{data?.title}
+
author
+
{data?.author}
+
+
+ {notification ? ( + <> + +   + + + ) : ( + + )} +   + +
+ {success &&
{success}
} + {error &&
{error.message}
} + {isMutating !== 0 &&
mutating
} + + ); +};