From cf45eabb4d879745577802fa89c4a18db0a289f7 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 19 Jan 2021 22:32:19 +0100 Subject: [PATCH 01/13] Add optimistic mode to useDataProvider --- examples/simple/src/posts/ResetViewsButton.js | 11 +- .../getDataProviderCallArguments.ts | 10 +- .../src/dataProvider/useDataProvider.ts | 187 +++++++++++++++--- packages/ra-core/src/types.ts | 2 + 4 files changed, 172 insertions(+), 38 deletions(-) diff --git a/examples/simple/src/posts/ResetViewsButton.js b/examples/simple/src/posts/ResetViewsButton.js index 80e291f0487..a9af0b20c5b 100644 --- a/examples/simple/src/posts/ResetViewsButton.js +++ b/examples/simple/src/posts/ResetViewsButton.js @@ -19,12 +19,9 @@ const ResetViewsButton = ({ resource, selectedIds }) => { { action: CRUD_UPDATE_MANY, onSuccess: () => { - notify( - 'ra.notification.updated', - 'info', - { smart_count: selectedIds.length }, - true - ); + notify('ra.notification.updated', 'info', { + smart_count: selectedIds.length, + }); unselectAll(resource); }, onFailure: error => @@ -34,7 +31,7 @@ const ResetViewsButton = ({ resource, selectedIds }) => { : error.message || 'ra.notification.http_error', 'warning' ), - undoable: true, + mode: 'optimistic', } ); diff --git a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts index 600d7eef670..9313c91f310 100644 --- a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts +++ b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts @@ -8,6 +8,7 @@ const OptionsProperties = [ 'onFailure', 'onSuccess', 'undoable', + 'mode', ]; const isDataProviderOptions = (value: any) => { @@ -20,7 +21,14 @@ const isDataProviderOptions = (value: any) => { // As all dataProvider methods do not have the same signature, we must differentiate // standard methods which have the (resource, params, options) signature // from the custom ones -export const getDataProviderCallArguments = (args: any[]) => { +export const getDataProviderCallArguments = ( + args: any[] +): { + resource: string; + payload: any; + options: UseDataProviderOptions; + allArguments: any[]; +} => { const lastArg = args[args.length - 1]; let allArguments = [...args]; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 007e5d4d336..a432617444c 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -44,7 +44,7 @@ let nbRemainingOptimisticCalls = 0; * @example Basic usage * * import * as React from 'react'; -import { useState } from 'react'; + * import { useState } from 'react'; * import { useDataProvider } from 'react-admin'; * * const PostList = () => { @@ -143,6 +143,7 @@ const useDataProvider = (): DataProviderProxy => { undoable = false, onSuccess = undefined, onFailure = undefined, + mode = 'regular', ...rest } = options || {}; @@ -161,7 +162,7 @@ const useDataProvider = (): DataProviderProxy => { 'The onFailure option must be a function' ); } - if (undoable && !onSuccess) { + if ((undoable || mode === 'undoable') && !onSuccess) { throw new Error( 'You must pass an onSuccess callback calling notify() to use the undoable mode' ); @@ -180,13 +181,18 @@ const useDataProvider = (): DataProviderProxy => { store, type, allArguments, + mode, undoable, }; if (isOptimistic) { // in optimistic mode, all fetch calls are stacked, to be // executed once the dataProvider leaves optimistic mode. // In the meantime, the admin uses data from the store. - if (undoable) { + if ( + undoable || + mode === 'undoable' || + mode === 'optimistic' + ) { undoableOptimisticCalls.push(params); } else { optimisticCalls.push(params); @@ -232,6 +238,7 @@ const doQuery = ({ dispatch, store, undoable, + mode, logoutIfAccessDenied, allArguments, }) => { @@ -247,34 +254,154 @@ const doQuery = ({ resourceState, dispatch, }); + } else if (mode === 'optimistic') { + return performOptimisticQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); + } else if (mode === 'undoable' || undoable) { + return performUndoableQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); + } else { + return performQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); } - return undoable - ? performUndoableQuery({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, - }) - : performQuery({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, - }); +}; + +/** + * In optimistic mode, the hook dispatches an optimistic action and executes + * the success side effects right away. Then it immediately calls the dataProvider. + * + * We call that "optimistic" because the hook returns a resolved Promise + * immediately (although it has an empty value). That only works if the + * caller reads the result from the Redux store, not from the Promise. + */ +const performOptimisticQuery = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, +}: QueryFunctionParams): Promise<{}> => { + dispatch(startOptimisticMode()); + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_OPTIMISTIC`, + payload, + meta: { + resource, + fetch: getFetchType(type), + optimistic: true, + }, + }); + onSuccess && onSuccess({}); + setTimeout(() => { + dispatch(stopOptimisticMode()); + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + try { + dataProvider[type] + .apply( + dataProvider, + typeof resource !== 'undefined' + ? [resource, payload] + : allArguments + ) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + replayOptimisticCalls(); + }) + .catch(error => { + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + return logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + }); + }); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.error(e); + } + throw new Error( + 'The dataProvider threw an error. It should return a rejected Promise instead.' + ); + } + }); + return Promise.resolve({}); }; /** diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 529d36b3a45..1e3c8f99886 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -288,7 +288,9 @@ export interface UseDataProviderOptions { action?: string; fetch?: string; meta?: object; + // @deprecated use mode: 'undoable' instead undoable?: boolean; + mode?: 'regular' | 'optimistic' | 'undoable'; onSuccess?: any; onFailure?: any; } From 2e9fe285942a2198aa558cc55bfd8358fef80889 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 25 Jan 2021 22:31:54 +0100 Subject: [PATCH 02/13] Add unit tests --- .../button/useDeleteWithUndoController.tsx | 2 +- .../controller/details/useEditController.ts | 14 +- .../ra-core/src/dataProvider/useCreate.ts | 9 +- .../src/dataProvider/useDataProvider.spec.js | 152 ++++++++++++++++++ .../src/dataProvider/useDataProvider.ts | 21 +-- .../ra-core/src/dataProvider/useDelete.ts | 4 +- .../ra-core/src/dataProvider/useMutation.ts | 5 +- packages/ra-core/src/types.ts | 5 +- packages/ra-ui-materialui/src/detail/Edit.tsx | 4 +- .../ra-ui-materialui/src/detail/EditView.tsx | 3 +- packages/ra-ui-materialui/src/types.ts | 3 + 11 files changed, 198 insertions(+), 24 deletions(-) diff --git a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx index 4b054888e33..37050decb36 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx @@ -99,7 +99,7 @@ const useDeleteWithUndoController = ( ); refresh(); }, - undoable: true, + mutationMode: 'undoable', }); const handleDelete = useCallback( event => { diff --git a/packages/ra-core/src/controller/details/useEditController.ts b/packages/ra-core/src/controller/details/useEditController.ts index 47a64f3e492..9f00cce7cd0 100644 --- a/packages/ra-core/src/controller/details/useEditController.ts +++ b/packages/ra-core/src/controller/details/useEditController.ts @@ -3,7 +3,7 @@ import inflection from 'inflection'; import useVersion from '../useVersion'; import { useCheckMinimumRequiredProps } from '../checkMinimumRequiredProps'; -import { Record, Identifier } from '../../types'; +import { Record, Identifier, MutationMode } from '../../types'; import { useNotify, useRedirect, @@ -32,7 +32,9 @@ export interface EditProps { hasList?: boolean; id?: Identifier; resource?: string; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; + mutationMode?: MutationMode; onSuccess?: OnSuccess; onFailure?: OnFailure; transform?: TransformData; @@ -103,9 +105,11 @@ export const useEditController = ( hasShow, id, successMessage, + // @deprecated use mutationMode: undoable instaed undoable = true, onSuccess, onFailure, + mutationMode = undoable ? 'undoable' : undefined, transform, } = props; const resource = useResourceContext(props); @@ -193,7 +197,7 @@ export const useEditController = ( { smart_count: 1, }, - undoable + mutationMode === 'undoable' ); redirect(redirectTo, basePath, data.id, data); }, @@ -217,11 +221,11 @@ export const useEditController = ( : undefined, } ); - if (undoable) { + if (mutationMode === 'undoable') { refresh(); } }, - undoable, + mutationMode, } ) ), @@ -230,12 +234,12 @@ export const useEditController = ( update, onSuccessRef, onFailureRef, - undoable, notify, successMessage, redirect, basePath, refresh, + mutationMode, ] ); diff --git a/packages/ra-core/src/dataProvider/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts index 6cc84fbb9c0..54297bfe28d 100644 --- a/packages/ra-core/src/dataProvider/useCreate.ts +++ b/packages/ra-core/src/dataProvider/useCreate.ts @@ -1,4 +1,4 @@ -import useMutation from './useMutation'; +import useMutation, { MutationOptions } from './useMutation'; /** * Get a callback to call the dataProvider.create() method, the result and the loading state. @@ -26,7 +26,10 @@ import useMutation from './useMutation'; * return ; * }; */ -const useCreate = (resource: string, data: any = {}, options?: any) => - useMutation({ type: 'create', resource, payload: { data } }, options); +const useCreate = ( + resource: string, + data: any = {}, + options?: MutationOptions +) => useMutation({ type: 'create', resource, payload: { data } }, options); export default useCreate; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.spec.js b/packages/ra-core/src/dataProvider/useDataProvider.spec.js index 6e143d64359..70ea58dfac4 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.spec.js +++ b/packages/ra-core/src/dataProvider/useDataProvider.spec.js @@ -8,6 +8,7 @@ import useDataProvider from './useDataProvider'; import useUpdate from './useUpdate'; import { DataProviderContext } from '../dataProvider'; import { useRefresh } from '../sideEffect'; +import undoableEventEmitter from './undoableEventEmitter'; const UseGetOne = () => { const [data, setData] = useState(); @@ -317,6 +318,157 @@ describe('useDataProvider', () => { expect(onFailure.mock.calls).toHaveLength(1); expect(onFailure.mock.calls[0][0]).toEqual(new Error('foo')); }); + + describe('mutationMode', () => { + it('should wait for response to dispatch side effects in pessimistic mode', async () => { + let resolveUpdate; + const update = jest.fn(() => + new Promise(resolve => { + resolveUpdate = resolve; + }).then(() => ({ data: { id: 1, updated: true } })) + ); + const dataProvider = { update }; + const UpdateButton = () => { + const [updated, setUpdated] = useState(false); + const dataProvider = useDataProvider(); + return ( + + ); + }; + const { getByText, queryByText } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // click on the update button + await act(async () => { + fireEvent.click(getByText('update')); + await new Promise(r => setTimeout(r)); + }); + expect(update).toBeCalledTimes(1); + // make sure the side effect hasn't been applied yet + expect(queryByText('(updated)')).toBeNull(); + await act(() => { + resolveUpdate(); + }); + // side effects should be applied now + expect(queryByText('(updated)')).not.toBeNull(); + }); + + it('should not wait for response to dispatch side effects in optimistic mode', async () => { + let resolveUpdate; + const update = jest.fn(() => + new Promise(resolve => { + resolveUpdate = resolve; + }).then(() => ({ data: { id: 1, updated: true } })) + ); + const dataProvider = { update }; + const UpdateButton = () => { + const [updated, setUpdated] = useState(false); + const dataProvider = useDataProvider(); + return ( + + ); + }; + const { getByText, queryByText } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // click on the update button + await act(async () => { + fireEvent.click(getByText('update')); + await new Promise(r => setTimeout(r)); + }); + // side effects should be applied now + expect(queryByText('(updated)')).not.toBeNull(); + expect(update).toBeCalledTimes(1); + await act(() => { + resolveUpdate(); + }); + }); + + it('should not wait for response to dispatch side effects in undoable mode', async () => { + const update = jest.fn({ + apply: () => + Promise.resolve({ data: { id: 1, updated: true } }), + }); + const dataProvider = { update }; + const UpdateButton = () => { + const [updated, setUpdated] = useState(false); + const dataProvider = useDataProvider(); + return ( + + ); + }; + const { getByText, queryByText } = renderWithRedux( + + + , + { admin: { resources: { posts: { data: {}, list: {} } } } } + ); + // click on the update button + await act(async () => { + fireEvent.click(getByText('update')); + await new Promise(r => setTimeout(r)); + }); + // side effects should be applied now + expect(queryByText('(updated)')).not.toBeNull(); + // update shouldn't be called at all + expect(update).toBeCalledTimes(0); + await act(() => { + undoableEventEmitter.emit('end', {}); + }); + expect(update).toBeCalledTimes(1); + }); + }); }); describe('cache', () => { diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index a432617444c..3bb4eda5514 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -37,7 +37,7 @@ let nbRemainingOptimisticCalls = 0; * In addition to the 2 usual parameters of the dataProvider methods (resource, * payload), the Proxy supports a third parameter for every call. It's an * object literal which may contain side effects, or make the action optimistic - * (with undoable: true). + * (with mutationMode: optimistic) or undoable (with mutationMode: undoable). * * @return dataProvider * @@ -143,7 +143,7 @@ const useDataProvider = (): DataProviderProxy => { undoable = false, onSuccess = undefined, onFailure = undefined, - mode = 'regular', + mutationMode = 'pessimistic', ...rest } = options || {}; @@ -162,7 +162,10 @@ const useDataProvider = (): DataProviderProxy => { 'The onFailure option must be a function' ); } - if ((undoable || mode === 'undoable') && !onSuccess) { + if ( + (undoable || mutationMode === 'undoable') && + !onSuccess + ) { throw new Error( 'You must pass an onSuccess callback calling notify() to use the undoable mode' ); @@ -181,7 +184,7 @@ const useDataProvider = (): DataProviderProxy => { store, type, allArguments, - mode, + mutationMode, undoable, }; if (isOptimistic) { @@ -190,8 +193,8 @@ const useDataProvider = (): DataProviderProxy => { // In the meantime, the admin uses data from the store. if ( undoable || - mode === 'undoable' || - mode === 'optimistic' + mutationMode === 'undoable' || + mutationMode === 'optimistic' ) { undoableOptimisticCalls.push(params); } else { @@ -238,7 +241,7 @@ const doQuery = ({ dispatch, store, undoable, - mode, + mutationMode, logoutIfAccessDenied, allArguments, }) => { @@ -254,7 +257,7 @@ const doQuery = ({ resourceState, dispatch, }); - } else if (mode === 'optimistic') { + } else if (mutationMode === 'optimistic') { return performOptimisticQuery({ type, payload, @@ -268,7 +271,7 @@ const doQuery = ({ logoutIfAccessDenied, allArguments, }); - } else if (mode === 'undoable' || undoable) { + } else if (mutationMode === 'undoable' || undoable) { return performUndoableQuery({ type, payload, diff --git a/packages/ra-core/src/dataProvider/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts index 303d05e7451..077912d2560 100644 --- a/packages/ra-core/src/dataProvider/useDelete.ts +++ b/packages/ra-core/src/dataProvider/useDelete.ts @@ -1,4 +1,4 @@ -import useMutation from './useMutation'; +import useMutation, { MutationOptions } from './useMutation'; import { Identifier } from '../types'; /** @@ -32,7 +32,7 @@ const useDelete = ( resource: string, id: Identifier, previousData: any = {}, - options?: any + options?: MutationOptions ) => useMutation( { type: 'delete', resource, payload: { id, previousData } }, diff --git a/packages/ra-core/src/dataProvider/useMutation.ts b/packages/ra-core/src/dataProvider/useMutation.ts index c10e3bf2746..8f9c041334a 100644 --- a/packages/ra-core/src/dataProvider/useMutation.ts +++ b/packages/ra-core/src/dataProvider/useMutation.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import merge from 'lodash/merge'; import { useSafeSetState } from '../util/hooks'; +import { MutationMode } from '../types'; import useDataProvider from './useDataProvider'; import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects'; @@ -203,10 +204,12 @@ export interface Mutation { export interface MutationOptions { action?: string; - undoable?: boolean; onSuccess?: (response: any) => any | Object; onFailure?: (error?: any) => any | Object; withDeclarativeSideEffectsSupport?: boolean; + /** @deprecated use mutationMode: undoable instead */ + undoable?: boolean; + mutationMode?: MutationMode; } export type UseMutationValue = [ diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 1e3c8f99886..413e8775d4c 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -10,6 +10,7 @@ import { Location, History, LocationState } from 'history'; import { WithPermissionsChildrenParams } from './auth/WithPermissions'; import { AuthActionType } from './auth/types'; +import { Mutation } from './dataProvider/useMutation'; /** * data types @@ -284,13 +285,15 @@ export type DataProviderProxy = { [key: string]: any; }; +export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable'; + export interface UseDataProviderOptions { action?: string; fetch?: string; meta?: object; // @deprecated use mode: 'undoable' instead undoable?: boolean; - mode?: 'regular' | 'optimistic' | 'undoable'; + mutationMode?: MutationMode; onSuccess?: any; onFailure?: any; } diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index 39c730db8b6..4dce1bc0a34 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -26,7 +26,8 @@ import { EditView } from './EditView'; * - component * - successMessage * - title - * - undoable + * - mutationMode + * - undoable (deprecated) * * @example * @@ -93,4 +94,5 @@ Edit.propTypes = { onFailure: PropTypes.func, transform: PropTypes.func, undoable: PropTypes.bool, + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), }; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index e3d350dc461..014262e3d9b 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -25,6 +25,7 @@ export const EditView = (props: EditViewProps) => { component: Content, title, undoable, + mutationMode, ...rest } = props; @@ -89,7 +90,7 @@ export const EditView = (props: EditViewProps) => { resource, save, saving, - undoable, + undoable: undoable || mutationMode === 'undoable', version, }) ) : ( diff --git a/packages/ra-ui-materialui/src/types.ts b/packages/ra-ui-materialui/src/types.ts index 517f8f48171..876b54242b5 100644 --- a/packages/ra-ui-materialui/src/types.ts +++ b/packages/ra-ui-materialui/src/types.ts @@ -7,6 +7,7 @@ import { Record as RaRecord, ResourceComponentProps, ResourceComponentPropsWithId, + MutationMode, } from 'ra-core'; export interface ListProps extends ResourceComponentProps { @@ -34,7 +35,9 @@ export interface EditProps extends ResourceComponentPropsWithId { classes?: any; className?: string; component?: ElementType; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; + mutationMode?: MutationMode; onSuccess?: (data: RaRecord) => void; onFailure?: (error: any) => void; transform?: (data: RaRecord) => RaRecord; From 952b3a09aed619a12abddaf1dad9134951f23fac Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Thu, 28 Jan 2021 21:21:16 +0100 Subject: [PATCH 03/13] Apply suggestions from code review Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- examples/simple/src/posts/ResetViewsButton.js | 2 +- .../ra-core/src/dataProvider/getDataProviderCallArguments.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/simple/src/posts/ResetViewsButton.js b/examples/simple/src/posts/ResetViewsButton.js index a9af0b20c5b..ede7b7afa39 100644 --- a/examples/simple/src/posts/ResetViewsButton.js +++ b/examples/simple/src/posts/ResetViewsButton.js @@ -31,7 +31,7 @@ const ResetViewsButton = ({ resource, selectedIds }) => { : error.message || 'ra.notification.http_error', 'warning' ), - mode: 'optimistic', + mutationMode: 'optimistic', } ); diff --git a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts index 9313c91f310..68f35a1b94e 100644 --- a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts +++ b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts @@ -8,7 +8,7 @@ const OptionsProperties = [ 'onFailure', 'onSuccess', 'undoable', - 'mode', + 'mutationMode', ]; const isDataProviderOptions = (value: any) => { From b0cd0ab12580093a028454d7ec26604de3619fbc Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 28 Jan 2021 21:35:53 +0100 Subject: [PATCH 04/13] Review --- .../src/dataProvider/useDataProvider.ts | 36 +++++++++---------- .../ra-core/src/dataProvider/useDeleteMany.ts | 9 +++-- .../ra-core/src/dataProvider/useUpdate.ts | 4 +-- .../ra-core/src/dataProvider/useUpdateMany.ts | 4 +-- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 3bb4eda5514..1ae25657429 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -21,9 +21,9 @@ import { getDataProviderCallArguments } from './getDataProviderCallArguments'; // List of dataProvider calls emitted while in optimistic mode. // These calls get replayed once the dataProvider exits optimistic mode -const optimisticCalls = []; -const undoableOptimisticCalls = []; -let nbRemainingOptimisticCalls = 0; +const stackedCalls = []; +const stackedOptimisticCalls = []; +let nbRemainingStackedCalls = 0; /** * Hook for getting a dataProvider @@ -143,7 +143,7 @@ const useDataProvider = (): DataProviderProxy => { undoable = false, onSuccess = undefined, onFailure = undefined, - mutationMode = 'pessimistic', + mutationMode = undoable ? 'undoable' : 'pessimistic', ...rest } = options || {}; @@ -162,10 +162,7 @@ const useDataProvider = (): DataProviderProxy => { 'The onFailure option must be a function' ); } - if ( - (undoable || mutationMode === 'undoable') && - !onSuccess - ) { + if (mutationMode === 'undoable' && !onSuccess) { throw new Error( 'You must pass an onSuccess callback calling notify() to use the undoable mode' ); @@ -192,21 +189,20 @@ const useDataProvider = (): DataProviderProxy => { // executed once the dataProvider leaves optimistic mode. // In the meantime, the admin uses data from the store. if ( - undoable || mutationMode === 'undoable' || mutationMode === 'optimistic' ) { - undoableOptimisticCalls.push(params); + stackedOptimisticCalls.push(params); } else { - optimisticCalls.push(params); + stackedCalls.push(params); } - nbRemainingOptimisticCalls++; + nbRemainingStackedCalls++; // Return a Promise that only resolves when the optimistic call was made // otherwise hooks like useQueryWithStore will return loaded = true // before the content actually reaches the Redux store. // But as we can't determine when this particular query was finished, // the Promise resolves only when *all* optimistic queries are done. - return waitFor(() => nbRemainingOptimisticCalls === 0); + return waitFor(() => nbRemainingStackedCalls === 0); } return doQuery(params); }; @@ -565,30 +561,30 @@ const replayOptimisticCalls = async () => { // queries do not conflict with this one. // We only handle all side effects queries if there are no more undoable queries - if (undoableOptimisticCalls.length > 0) { - clone = [...undoableOptimisticCalls]; + if (stackedOptimisticCalls.length > 0) { + clone = [...stackedOptimisticCalls]; // remove these calls from the list *before* doing them // because side effects in the calls can add more calls // so we don't want to erase these. - undoableOptimisticCalls.splice(0, undoableOptimisticCalls.length); + stackedOptimisticCalls.splice(0, stackedOptimisticCalls.length); await Promise.all( clone.map(params => Promise.resolve(doQuery.call(null, params))) ); // once the calls are finished, decrease the number of remaining calls - nbRemainingOptimisticCalls -= clone.length; + nbRemainingStackedCalls -= clone.length; } else { - clone = [...optimisticCalls]; + clone = [...stackedCalls]; // remove these calls from the list *before* doing them // because side effects in the calls can add more calls // so we don't want to erase these. - optimisticCalls.splice(0, optimisticCalls.length); + stackedCalls.splice(0, stackedCalls.length); await Promise.all( clone.map(params => Promise.resolve(doQuery.call(null, params))) ); // once the calls are finished, decrease the number of remaining calls - nbRemainingOptimisticCalls -= clone.length; + nbRemainingStackedCalls -= clone.length; } }; diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts index b53daed5fca..a4661e49152 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.ts +++ b/packages/ra-core/src/dataProvider/useDeleteMany.ts @@ -1,4 +1,4 @@ -import useMutation from './useMutation'; +import useMutation, { MutationOptions } from './useMutation'; import { Identifier } from '../types'; /** @@ -27,7 +27,10 @@ import { Identifier } from '../types'; * return ; * }; */ -const useDeleteMany = (resource: string, ids: Identifier[], options?: any) => - useMutation({ type: 'deleteMany', resource, payload: { ids } }, options); +const useDeleteMany = ( + resource: string, + ids: Identifier[], + options?: MutationOptions +) => useMutation({ type: 'deleteMany', resource, payload: { ids } }, options); export default useDeleteMany; diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 6231ae0b076..a03950ea077 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -1,5 +1,5 @@ import { Identifier } from '../types'; -import useMutation from './useMutation'; +import useMutation, { MutationOptions } from './useMutation'; /** * Get a callback to call the dataProvider.update() method, the result and the loading state. @@ -34,7 +34,7 @@ const useUpdate = ( id: Identifier, data?: any, previousData?: any, - options?: any + options?: MutationOptions ) => useMutation( { type: 'update', resource, payload: { id, data, previousData } }, diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts index 40d76012fe4..88538a6bac2 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -1,4 +1,4 @@ -import useMutation from './useMutation'; +import useMutation, { MutationOptions } from './useMutation'; import { Identifier } from '../types'; /** @@ -32,7 +32,7 @@ const useUpdateMany = ( resource: string, ids: Identifier[], data: any, - options?: any + options?: MutationOptions ) => useMutation( { type: 'updateMany', resource, payload: { ids, data } }, From 4b00f2bc55057bc10b49d4f848922384fd38f37f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 28 Jan 2021 22:29:01 +0100 Subject: [PATCH 05/13] Refactor useDataProvider for better readability --- .../performQuery/QueryFunctionParams.ts | 18 + .../performQuery/answerWithCache.ts | 35 ++ .../src/dataProvider/performQuery/doQuery.ts | 84 +++ .../src/dataProvider/performQuery/index.ts | 8 + .../performQuery/performOptimisticQuery.ts | 119 ++++ .../performQuery/performPessimisticQuery.ts | 100 ++++ .../performQuery/performUndoableQuery.ts | 166 ++++++ .../dataProvider/performQuery/stackedCalls.ts | 53 ++ .../src/dataProvider/useDataProvider.ts | 561 +----------------- 9 files changed, 610 insertions(+), 534 deletions(-) create mode 100644 packages/ra-core/src/dataProvider/performQuery/QueryFunctionParams.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/answerWithCache.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/doQuery.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/index.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/performOptimisticQuery.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/performPessimisticQuery.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/performUndoableQuery.ts create mode 100644 packages/ra-core/src/dataProvider/performQuery/stackedCalls.ts diff --git a/packages/ra-core/src/dataProvider/performQuery/QueryFunctionParams.ts b/packages/ra-core/src/dataProvider/performQuery/QueryFunctionParams.ts new file mode 100644 index 00000000000..503e0685687 --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/QueryFunctionParams.ts @@ -0,0 +1,18 @@ +import { Dispatch } from 'redux'; +import { DataProvider } from '../../types'; + +export interface QueryFunctionParams { + /** The fetch type, e.g. `UPDATE_MANY` */ + type: string; + payload: any; + resource: string; + /** The root action name, e.g. `CRUD_GET_MANY` */ + action: string; + rest: any; + onSuccess?: (args?: any) => void; + onFailure?: (error: any) => void; + dataProvider: DataProvider; + dispatch: Dispatch; + logoutIfAccessDenied: (error?: any) => Promise; + allArguments: any[]; +} diff --git a/packages/ra-core/src/dataProvider/performQuery/answerWithCache.ts b/packages/ra-core/src/dataProvider/performQuery/answerWithCache.ts new file mode 100644 index 00000000000..d721c1d5259 --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/answerWithCache.ts @@ -0,0 +1,35 @@ +import { getResultFromCache } from '../replyWithCache'; +import getFetchType from '../getFetchType'; +import { FETCH_END } from '../../actions/fetchActions'; + +export const answerWithCache = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + resourceState, + dispatch, +}) => { + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + const response = getResultFromCache(type, payload, resourceState); + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + fromCache: true, + }, + }); + onSuccess && onSuccess(response); + return Promise.resolve(response); +}; diff --git a/packages/ra-core/src/dataProvider/performQuery/doQuery.ts b/packages/ra-core/src/dataProvider/performQuery/doQuery.ts new file mode 100644 index 00000000000..c9ecd565da2 --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/doQuery.ts @@ -0,0 +1,84 @@ +import { performOptimisticQuery } from './performOptimisticQuery'; +import { performUndoableQuery } from './performUndoableQuery'; +import { performPessimisticQuery } from './performPessimisticQuery'; +import { answerWithCache } from './answerWithCache'; +import { canReplyWithCache } from '../replyWithCache'; + +/** + * Execute a dataProvider call + * + * Delegates execution to cache, optimistic, undoable, or pessimistic queries + * + * @see useDataProvider + */ +export const doQuery = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + store, + mutationMode, + logoutIfAccessDenied, + allArguments, +}) => { + const resourceState = store.getState().admin.resources[resource]; + if (canReplyWithCache(type, payload, resourceState)) { + return answerWithCache({ + type, + payload, + resource, + action, + rest, + onSuccess, + resourceState, + dispatch, + }); + } else if (mutationMode === 'optimistic') { + return performOptimisticQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); + } else if (mutationMode === 'undoable') { + return performUndoableQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); + } else { + return performPessimisticQuery({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, + }); + } +}; diff --git a/packages/ra-core/src/dataProvider/performQuery/index.ts b/packages/ra-core/src/dataProvider/performQuery/index.ts new file mode 100644 index 00000000000..9e31831e60d --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/index.ts @@ -0,0 +1,8 @@ +import { doQuery } from './doQuery'; +import { + stackCall, + stackOptimisticCall, + getRemainingStackedCalls, +} from './stackedCalls'; + +export { doQuery, stackCall, stackOptimisticCall, getRemainingStackedCalls }; diff --git a/packages/ra-core/src/dataProvider/performQuery/performOptimisticQuery.ts b/packages/ra-core/src/dataProvider/performQuery/performOptimisticQuery.ts new file mode 100644 index 00000000000..e016658de3b --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/performOptimisticQuery.ts @@ -0,0 +1,119 @@ +import validateResponseFormat from '../validateResponseFormat'; +import getFetchType from '../getFetchType'; +import { + startOptimisticMode, + stopOptimisticMode, +} from '../../actions/undoActions'; +import { + FETCH_END, + FETCH_ERROR, + FETCH_START, +} from '../../actions/fetchActions'; +import { replayStackedCalls } from './stackedCalls'; +import { QueryFunctionParams } from './QueryFunctionParams'; + +/** + * In optimistic mode, the useDataProvider hook dispatches an optimistic action + * and executes the success side effects right away. Then it immediately calls + * the dataProvider. + * + * We call that "optimistic" because the hook returns a resolved Promise + * immediately (although it has an empty value). That only works if the + * caller reads the result from the Redux store, not from the Promise. + */ +export const performOptimisticQuery = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, +}: QueryFunctionParams): Promise<{}> => { + dispatch(startOptimisticMode()); + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_OPTIMISTIC`, + payload, + meta: { + resource, + fetch: getFetchType(type), + optimistic: true, + }, + }); + onSuccess && onSuccess({}); + setTimeout(() => { + dispatch(stopOptimisticMode()); + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + try { + dataProvider[type] + .apply( + dataProvider, + typeof resource !== 'undefined' + ? [resource, payload] + : allArguments + ) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + replayStackedCalls(); + }) + .catch(error => { + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + return logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + }); + }); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.error(e); + } + throw new Error( + 'The dataProvider threw an error. It should return a rejected Promise instead.' + ); + } + }); + return Promise.resolve({}); +}; diff --git a/packages/ra-core/src/dataProvider/performQuery/performPessimisticQuery.ts b/packages/ra-core/src/dataProvider/performQuery/performPessimisticQuery.ts new file mode 100644 index 00000000000..9daeec1b245 --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/performPessimisticQuery.ts @@ -0,0 +1,100 @@ +import validateResponseFormat from '../validateResponseFormat'; +import getFetchType from '../getFetchType'; +import { + FETCH_END, + FETCH_ERROR, + FETCH_START, +} from '../../actions/fetchActions'; +import { QueryFunctionParams } from './QueryFunctionParams'; + +/** + * In pessimistic mode, the useDataProvider hook calls the dataProvider. When a + * successful response arrives, the hook dispatches a SUCCESS action, executes + * success side effects and returns the response. If the response is an error, + * the hook dispatches a FAILURE action, executes failure side effects, and + * throws an error. + */ +export const performPessimisticQuery = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, +}: QueryFunctionParams): Promise => { + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + + try { + return dataProvider[type] + .apply( + dataProvider, + typeof resource !== 'undefined' + ? [resource, payload] + : allArguments + ) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + onSuccess && onSuccess(response); + return response; + }) + .catch(error => { + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + return logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + throw error; + }); + }); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.error(e); + } + throw new Error( + 'The dataProvider threw an error. It should return a rejected Promise instead.' + ); + } +}; diff --git a/packages/ra-core/src/dataProvider/performQuery/performUndoableQuery.ts b/packages/ra-core/src/dataProvider/performQuery/performUndoableQuery.ts new file mode 100644 index 00000000000..8716f80d64f --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/performUndoableQuery.ts @@ -0,0 +1,166 @@ +import validateResponseFormat from '../validateResponseFormat'; +import getFetchType from '../getFetchType'; +import undoableEventEmitter from '../undoableEventEmitter'; +import { + startOptimisticMode, + stopOptimisticMode, +} from '../../actions/undoActions'; +import { showNotification } from '../../actions/notificationActions'; +import { refreshView } from '../../actions/uiActions'; +import { + FETCH_END, + FETCH_ERROR, + FETCH_START, +} from '../../actions/fetchActions'; +import { replayStackedCalls } from './stackedCalls'; +import { QueryFunctionParams } from './QueryFunctionParams'; + +/** + * In undoable mode, the hook dispatches an optimistic action and executes + * the success side effects right away. Then it waits for a few seconds to + * actually call the dataProvider - unless the user dispatches an Undo action. + * + * We call that "optimistic" because the hook returns a resolved Promise + * immediately (although it has an empty value). That only works if the + * caller reads the result from the Redux store, not from the Promise. + */ +export const performUndoableQuery = ({ + type, + payload, + resource, + action, + rest, + onSuccess, + onFailure, + dataProvider, + dispatch, + logoutIfAccessDenied, + allArguments, +}: QueryFunctionParams): Promise<{}> => { + dispatch(startOptimisticMode()); + if (window) { + window.addEventListener('beforeunload', warnBeforeClosingWindow, { + capture: true, + }); + } + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_OPTIMISTIC`, + payload, + meta: { + resource, + fetch: getFetchType(type), + optimistic: true, + }, + }); + onSuccess && onSuccess({}); + undoableEventEmitter.once('end', ({ isUndo }) => { + dispatch(stopOptimisticMode()); + if (isUndo) { + dispatch(showNotification('ra.notification.canceled')); + dispatch(refreshView()); + if (window) { + window.removeEventListener( + 'beforeunload', + warnBeforeClosingWindow, + { + capture: true, + } + ); + } + return; + } + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + try { + dataProvider[type] + .apply( + dataProvider, + typeof resource !== 'undefined' + ? [resource, payload] + : allArguments + ) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + if (window) { + window.removeEventListener( + 'beforeunload', + warnBeforeClosingWindow, + { + capture: true, + } + ); + } + replayStackedCalls(); + }) + .catch(error => { + if (window) { + window.removeEventListener( + 'beforeunload', + warnBeforeClosingWindow, + { + capture: true, + } + ); + } + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + return logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: getFetchType(type), + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + }); + }); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.error(e); + } + throw new Error( + 'The dataProvider threw an error. It should return a rejected Promise instead.' + ); + } + }); + return Promise.resolve({}); +}; + +// event listener added as window.onbeforeunload when starting optimistic mode, and removed when it ends +const warnBeforeClosingWindow = event => { + event.preventDefault(); // standard + event.returnValue = ''; // Chrome + return 'Your latest modifications are not yet sent to the server. Are you sure?'; // Old IE +}; diff --git a/packages/ra-core/src/dataProvider/performQuery/stackedCalls.ts b/packages/ra-core/src/dataProvider/performQuery/stackedCalls.ts new file mode 100644 index 00000000000..19d453a35be --- /dev/null +++ b/packages/ra-core/src/dataProvider/performQuery/stackedCalls.ts @@ -0,0 +1,53 @@ +import { doQuery } from './doQuery'; + +let nbRemainingStackedCalls = 0; +export const getRemainingStackedCalls = () => nbRemainingStackedCalls; + +// List of dataProvider calls emitted while in optimistic mode. +// These calls get replayed once the dataProvider exits optimistic mode +const stackedCalls = []; +export const stackCall = params => { + stackedCalls.push(params); + nbRemainingStackedCalls++; +}; + +const stackedOptimisticCalls = []; +export const stackOptimisticCall = params => { + stackedOptimisticCalls.push(params); + nbRemainingStackedCalls++; +}; + +// Replay calls recorded while in optimistic mode +export const replayStackedCalls = async () => { + let clone; + + // We must perform any undoable queries first so that the effects of previous undoable + // queries do not conflict with this one. + + // We only handle all side effects queries if there are no more undoable queries + if (stackedOptimisticCalls.length > 0) { + clone = [...stackedOptimisticCalls]; + // remove these calls from the list *before* doing them + // because side effects in the calls can add more calls + // so we don't want to erase these. + stackedOptimisticCalls.splice(0, stackedOptimisticCalls.length); + + await Promise.all( + clone.map(params => Promise.resolve(doQuery.call(null, params))) + ); + // once the calls are finished, decrease the number of remaining calls + nbRemainingStackedCalls -= clone.length; + } else { + clone = [...stackedCalls]; + // remove these calls from the list *before* doing them + // because side effects in the calls can add more calls + // so we don't want to erase these. + stackedCalls.splice(0, stackedCalls.length); + + await Promise.all( + clone.map(params => Promise.resolve(doQuery.call(null, params))) + ); + // once the calls are finished, decrease the number of remaining calls + nbRemainingStackedCalls -= clone.length; + } +}; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 1ae25657429..e8f17b3ef9c 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -3,27 +3,16 @@ import { Dispatch } from 'redux'; import { useDispatch, useSelector, useStore } from 'react-redux'; import DataProviderContext from './DataProviderContext'; -import validateResponseFormat from './validateResponseFormat'; -import undoableEventEmitter from './undoableEventEmitter'; -import getFetchType from './getFetchType'; import defaultDataProvider from './defaultDataProvider'; -import { canReplyWithCache, getResultFromCache } from './replyWithCache'; -import { - startOptimisticMode, - stopOptimisticMode, -} from '../actions/undoActions'; -import { FETCH_END, FETCH_ERROR, FETCH_START } from '../actions/fetchActions'; -import { showNotification } from '../actions/notificationActions'; -import { refreshView } from '../actions/uiActions'; -import { ReduxState, DataProvider, DataProviderProxy } from '../types'; +import { ReduxState, DataProviderProxy } from '../types'; import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied'; import { getDataProviderCallArguments } from './getDataProviderCallArguments'; - -// List of dataProvider calls emitted while in optimistic mode. -// These calls get replayed once the dataProvider exits optimistic mode -const stackedCalls = []; -const stackedOptimisticCalls = []; -let nbRemainingStackedCalls = 0; +import { + doQuery, + stackCall, + stackOptimisticCall, + getRemainingStackedCalls, +} from './performQuery'; /** * Hook for getting a dataProvider @@ -117,6 +106,7 @@ let nbRemainingStackedCalls = 0; const useDataProvider = (): DataProviderProxy => { const dispatch = useDispatch() as Dispatch; const dataProvider = useContext(DataProviderContext) || defaultDataProvider; + // optimistic mode can be triggered by a previous optimistic or undoable query const isOptimistic = useSelector( (state: ReduxState) => state.admin.ui.optimistic ); @@ -169,42 +159,47 @@ const useDataProvider = (): DataProviderProxy => { } const params = { + resource, + type, + payload, action, - dataProvider, - dispatch, - logoutIfAccessDenied, onFailure, onSuccess, - payload, - resource, rest, + mutationMode, + // these ones are passed down because of the rules of hooks + dataProvider, store, - type, + dispatch, + logoutIfAccessDenied, allArguments, - mutationMode, - undoable, }; if (isOptimistic) { - // in optimistic mode, all fetch calls are stacked, to be + // When in optimistic mode, fetch calls aren't executed + // right away. Instead, they are are stacked, to be // executed once the dataProvider leaves optimistic mode. // In the meantime, the admin uses data from the store. if ( mutationMode === 'undoable' || mutationMode === 'optimistic' ) { - stackedOptimisticCalls.push(params); + // optimistic and undoable calls are added to a + // specific stack, as they must be replayed first + stackOptimisticCall(params); } else { - stackedCalls.push(params); + // pessimistic calls are added to the regular stack + // and will be replayed last + stackCall(params); } - nbRemainingStackedCalls++; // Return a Promise that only resolves when the optimistic call was made // otherwise hooks like useQueryWithStore will return loaded = true // before the content actually reaches the Redux store. // But as we can't determine when this particular query was finished, // the Promise resolves only when *all* optimistic queries are done. - return waitFor(() => nbRemainingStackedCalls === 0); + return waitFor(() => getRemainingStackedCalls() === 0); + } else { + return doQuery(params); } - return doQuery(params); }; }, }); @@ -225,506 +220,4 @@ const waitFor = (condition: () => boolean): Promise => condition() ? resolve() : later().then(() => waitFor(condition)) ); -const doQuery = ({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - store, - undoable, - mutationMode, - logoutIfAccessDenied, - allArguments, -}) => { - const resourceState = store.getState().admin.resources[resource]; - if (canReplyWithCache(type, payload, resourceState)) { - return answerWithCache({ - type, - payload, - resource, - action, - rest, - onSuccess, - resourceState, - dispatch, - }); - } else if (mutationMode === 'optimistic') { - return performOptimisticQuery({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, - }); - } else if (mutationMode === 'undoable' || undoable) { - return performUndoableQuery({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, - }); - } else { - return performQuery({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, - }); - } -}; - -/** - * In optimistic mode, the hook dispatches an optimistic action and executes - * the success side effects right away. Then it immediately calls the dataProvider. - * - * We call that "optimistic" because the hook returns a resolved Promise - * immediately (although it has an empty value). That only works if the - * caller reads the result from the Redux store, not from the Promise. - */ -const performOptimisticQuery = ({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, -}: QueryFunctionParams): Promise<{}> => { - dispatch(startOptimisticMode()); - dispatch({ - type: action, - payload, - meta: { resource, ...rest }, - }); - dispatch({ - type: `${action}_OPTIMISTIC`, - payload, - meta: { - resource, - fetch: getFetchType(type), - optimistic: true, - }, - }); - onSuccess && onSuccess({}); - setTimeout(() => { - dispatch(stopOptimisticMode()); - dispatch({ - type: `${action}_LOADING`, - payload, - meta: { resource, ...rest }, - }); - dispatch({ type: FETCH_START }); - try { - dataProvider[type] - .apply( - dataProvider, - typeof resource !== 'undefined' - ? [resource, payload] - : allArguments - ) - .then(response => { - if (process.env.NODE_ENV !== 'production') { - validateResponseFormat(response, type); - } - dispatch({ - type: `${action}_SUCCESS`, - payload: response, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_END, - }, - }); - dispatch({ type: FETCH_END }); - replayOptimisticCalls(); - }) - .catch(error => { - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - return logoutIfAccessDenied(error).then(loggedOut => { - if (loggedOut) return; - dispatch({ - type: `${action}_FAILURE`, - error: error.message ? error.message : error, - payload: error.body ? error.body : null, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_ERROR, - }, - }); - dispatch({ type: FETCH_ERROR, error }); - onFailure && onFailure(error); - }); - }); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - console.error(e); - } - throw new Error( - 'The dataProvider threw an error. It should return a rejected Promise instead.' - ); - } - }); - return Promise.resolve({}); -}; - -/** - * In undoable mode, the hook dispatches an optimistic action and executes - * the success side effects right away. Then it waits for a few seconds to - * actually call the dataProvider - unless the user dispatches an Undo action. - * - * We call that "optimistic" because the hook returns a resolved Promise - * immediately (although it has an empty value). That only works if the - * caller reads the result from the Redux store, not from the Promise. - */ -const performUndoableQuery = ({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, -}: QueryFunctionParams): Promise<{}> => { - dispatch(startOptimisticMode()); - if (window) { - window.addEventListener('beforeunload', warnBeforeClosingWindow, { - capture: true, - }); - } - dispatch({ - type: action, - payload, - meta: { resource, ...rest }, - }); - dispatch({ - type: `${action}_OPTIMISTIC`, - payload, - meta: { - resource, - fetch: getFetchType(type), - optimistic: true, - }, - }); - onSuccess && onSuccess({}); - undoableEventEmitter.once('end', ({ isUndo }) => { - dispatch(stopOptimisticMode()); - if (isUndo) { - dispatch(showNotification('ra.notification.canceled')); - dispatch(refreshView()); - if (window) { - window.removeEventListener( - 'beforeunload', - warnBeforeClosingWindow, - { - capture: true, - } - ); - } - return; - } - dispatch({ - type: `${action}_LOADING`, - payload, - meta: { resource, ...rest }, - }); - dispatch({ type: FETCH_START }); - try { - dataProvider[type] - .apply( - dataProvider, - typeof resource !== 'undefined' - ? [resource, payload] - : allArguments - ) - .then(response => { - if (process.env.NODE_ENV !== 'production') { - validateResponseFormat(response, type); - } - dispatch({ - type: `${action}_SUCCESS`, - payload: response, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_END, - }, - }); - dispatch({ type: FETCH_END }); - if (window) { - window.removeEventListener( - 'beforeunload', - warnBeforeClosingWindow, - { - capture: true, - } - ); - } - replayOptimisticCalls(); - }) - .catch(error => { - if (window) { - window.removeEventListener( - 'beforeunload', - warnBeforeClosingWindow, - { - capture: true, - } - ); - } - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - return logoutIfAccessDenied(error).then(loggedOut => { - if (loggedOut) return; - dispatch({ - type: `${action}_FAILURE`, - error: error.message ? error.message : error, - payload: error.body ? error.body : null, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_ERROR, - }, - }); - dispatch({ type: FETCH_ERROR, error }); - onFailure && onFailure(error); - }); - }); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - console.error(e); - } - throw new Error( - 'The dataProvider threw an error. It should return a rejected Promise instead.' - ); - } - }); - return Promise.resolve({}); -}; - -// event listener added as window.onbeforeunload when starting optimistic mode, and removed when it ends -const warnBeforeClosingWindow = event => { - event.preventDefault(); // standard - event.returnValue = ''; // Chrome - return 'Your latest modifications are not yet sent to the server. Are you sure?'; // Old IE -}; - -// Replay calls recorded while in optimistic mode -const replayOptimisticCalls = async () => { - let clone; - - // We must perform any undoable queries first so that the effects of previous undoable - // queries do not conflict with this one. - - // We only handle all side effects queries if there are no more undoable queries - if (stackedOptimisticCalls.length > 0) { - clone = [...stackedOptimisticCalls]; - // remove these calls from the list *before* doing them - // because side effects in the calls can add more calls - // so we don't want to erase these. - stackedOptimisticCalls.splice(0, stackedOptimisticCalls.length); - - await Promise.all( - clone.map(params => Promise.resolve(doQuery.call(null, params))) - ); - // once the calls are finished, decrease the number of remaining calls - nbRemainingStackedCalls -= clone.length; - } else { - clone = [...stackedCalls]; - // remove these calls from the list *before* doing them - // because side effects in the calls can add more calls - // so we don't want to erase these. - stackedCalls.splice(0, stackedCalls.length); - - await Promise.all( - clone.map(params => Promise.resolve(doQuery.call(null, params))) - ); - // once the calls are finished, decrease the number of remaining calls - nbRemainingStackedCalls -= clone.length; - } -}; - -/** - * In normal mode, the hook calls the dataProvider. When a successful response - * arrives, the hook dispatches a SUCCESS action, executes success side effects - * and returns the response. If the response is an error, the hook dispatches - * a FAILURE action, executes failure side effects, and throws an error. - */ -const performQuery = ({ - type, - payload, - resource, - action, - rest, - onSuccess, - onFailure, - dataProvider, - dispatch, - logoutIfAccessDenied, - allArguments, -}: QueryFunctionParams): Promise => { - dispatch({ - type: action, - payload, - meta: { resource, ...rest }, - }); - dispatch({ - type: `${action}_LOADING`, - payload, - meta: { resource, ...rest }, - }); - dispatch({ type: FETCH_START }); - - try { - return dataProvider[type] - .apply( - dataProvider, - typeof resource !== 'undefined' - ? [resource, payload] - : allArguments - ) - .then(response => { - if (process.env.NODE_ENV !== 'production') { - validateResponseFormat(response, type); - } - dispatch({ - type: `${action}_SUCCESS`, - payload: response, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_END, - }, - }); - dispatch({ type: FETCH_END }); - onSuccess && onSuccess(response); - return response; - }) - .catch(error => { - if (process.env.NODE_ENV !== 'production') { - console.error(error); - } - return logoutIfAccessDenied(error).then(loggedOut => { - if (loggedOut) return; - dispatch({ - type: `${action}_FAILURE`, - error: error.message ? error.message : error, - payload: error.body ? error.body : null, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_ERROR, - }, - }); - dispatch({ type: FETCH_ERROR, error }); - onFailure && onFailure(error); - throw error; - }); - }); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - console.error(e); - } - throw new Error( - 'The dataProvider threw an error. It should return a rejected Promise instead.' - ); - } -}; - -const answerWithCache = ({ - type, - payload, - resource, - action, - rest, - onSuccess, - resourceState, - dispatch, -}) => { - dispatch({ - type: action, - payload, - meta: { resource, ...rest }, - }); - const response = getResultFromCache(type, payload, resourceState); - dispatch({ - type: `${action}_SUCCESS`, - payload: response, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: getFetchType(type), - fetchStatus: FETCH_END, - fromCache: true, - }, - }); - onSuccess && onSuccess(response); - return Promise.resolve(response); -}; - -interface QueryFunctionParams { - /** The fetch type, e.g. `UPDATE_MANY` */ - type: string; - payload: any; - resource: string; - /** The root action name, e.g. `CRUD_GET_MANY` */ - action: string; - rest: any; - onSuccess?: (args?: any) => void; - onFailure?: (error: any) => void; - dataProvider: DataProvider; - dispatch: Dispatch; - logoutIfAccessDenied: (error?: any) => Promise; - allArguments: any[]; -} - export default useDataProvider; From a86a89f1329ed39c1a3b488d996498f963032531 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 28 Jan 2021 22:42:42 +0100 Subject: [PATCH 06/13] add doQuery types --- .../src/dataProvider/performQuery/doQuery.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/dataProvider/performQuery/doQuery.ts b/packages/ra-core/src/dataProvider/performQuery/doQuery.ts index c9ecd565da2..dd60aa1b291 100644 --- a/packages/ra-core/src/dataProvider/performQuery/doQuery.ts +++ b/packages/ra-core/src/dataProvider/performQuery/doQuery.ts @@ -3,6 +3,8 @@ import { performUndoableQuery } from './performUndoableQuery'; import { performPessimisticQuery } from './performPessimisticQuery'; import { answerWithCache } from './answerWithCache'; import { canReplyWithCache } from '../replyWithCache'; +import { QueryFunctionParams } from './QueryFunctionParams'; +import { MutationMode } from '../../types'; /** * Execute a dataProvider call @@ -21,11 +23,11 @@ export const doQuery = ({ onFailure, dataProvider, dispatch, - store, - mutationMode, logoutIfAccessDenied, allArguments, -}) => { + store, + mutationMode, +}: DoQueryParameters) => { const resourceState = store.getState().admin.resources[resource]; if (canReplyWithCache(type, payload, resourceState)) { return answerWithCache({ @@ -82,3 +84,8 @@ export const doQuery = ({ }); } }; + +interface DoQueryParameters extends QueryFunctionParams { + store: any; // unfortunately react-redux doesn't expose Store and AnyAction types, so we can't do better + mutationMode: MutationMode; +} From 2d955c4649de60a46a1c6d67e078c909700847c2 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 28 Jan 2021 23:29:57 +0100 Subject: [PATCH 07/13] Pass mutationMode down to toolbar --- examples/simple/src/posts/PostEdit.js | 1 + .../button/useDeleteWithConfirmController.tsx | 6 ++++-- .../src/controller/details/useEditController.ts | 2 +- .../ra-ui-materialui/src/button/DeleteButton.tsx | 13 ++++++++++--- .../src/button/DeleteWithConfirmButton.tsx | 4 ++++ packages/ra-ui-materialui/src/detail/Edit.tsx | 8 ++++---- packages/ra-ui-materialui/src/detail/EditView.tsx | 5 ++++- packages/ra-ui-materialui/src/form/SimpleForm.tsx | 11 ++++++++++- packages/ra-ui-materialui/src/form/TabbedForm.tsx | 11 ++++++++++- packages/ra-ui-materialui/src/form/Toolbar.tsx | 8 ++++++-- 10 files changed, 54 insertions(+), 15 deletions(-) diff --git a/examples/simple/src/posts/PostEdit.js b/examples/simple/src/posts/PostEdit.js index d5742055e75..371b9155382 100644 --- a/examples/simple/src/posts/PostEdit.js +++ b/examples/simple/src/posts/PostEdit.js @@ -53,6 +53,7 @@ const PostEdit = ({ permissions, ...props }) => ( { @@ -148,6 +149,7 @@ const useDeleteWithConfirmController = ( export interface UseDeleteWithConfirmControllerParams { basePath?: string; + mutationMode?: MutationMode; record?: Record; redirect?: RedirectionSideEffect; // @deprecated. This hook get the resource from the context diff --git a/packages/ra-core/src/controller/details/useEditController.ts b/packages/ra-core/src/controller/details/useEditController.ts index 9f00cce7cd0..5004a1272e8 100644 --- a/packages/ra-core/src/controller/details/useEditController.ts +++ b/packages/ra-core/src/controller/details/useEditController.ts @@ -105,7 +105,7 @@ export const useEditController = ( hasShow, id, successMessage, - // @deprecated use mutationMode: undoable instaed + // @deprecated use mutationMode: undoable instead undoable = true, onSuccess, onFailure, diff --git a/packages/ra-ui-materialui/src/button/DeleteButton.tsx b/packages/ra-ui-materialui/src/button/DeleteButton.tsx index 8d5539bb9ea..046e6e14faa 100644 --- a/packages/ra-ui-materialui/src/button/DeleteButton.tsx +++ b/packages/ra-ui-materialui/src/button/DeleteButton.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { FC, ReactElement, SyntheticEvent } from 'react'; import PropTypes from 'prop-types'; -import { Record, RedirectionSideEffect } from 'ra-core'; +import { Record, RedirectionSideEffect, MutationMode } from 'ra-core'; import { ButtonProps } from './Button'; import DeleteWithUndoButton from './DeleteWithUndoButton'; @@ -46,16 +46,21 @@ import DeleteWithConfirmButton from './DeleteWithConfirmButton'; */ const DeleteButton: FC = ({ undoable, + mutationMode, record, ...props }) => { if (!record || record.id == null) { return null; } - return undoable ? ( + return undoable || mutationMode === 'undoable' ? ( ) : ( - + ); }; @@ -65,6 +70,7 @@ interface Props { className?: string; icon?: ReactElement; label?: string; + mutationMode?: MutationMode; onClick?: (e: MouseEvent) => void; record?: Record; redirect?: RedirectionSideEffect; @@ -76,6 +82,7 @@ interface Props { pristine?: boolean; saving?: boolean; submitOnEnter?: boolean; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; } diff --git a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx index a0ad5468412..0e4a3063e6b 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx @@ -13,6 +13,7 @@ import classnames from 'classnames'; import inflection from 'inflection'; import { useTranslate, + MutationMode, Record, RedirectionSideEffect, useDeleteWithConfirmController, @@ -33,6 +34,7 @@ const DeleteWithConfirmButton: FC = props => { confirmContent = 'ra.message.delete_content', icon = defaultIcon, label = 'ra.action.delete', + mutationMode, onClick, record, redirect = 'list', @@ -52,6 +54,7 @@ const DeleteWithConfirmButton: FC = props => { record, redirect, basePath, + mutationMode, onClick, onSuccess, onFailure, @@ -124,6 +127,7 @@ interface Props { confirmContent?: string; icon?: ReactElement; label?: string; + mutationMode?: MutationMode; onClick?: ReactEventHandler; record?: Record; redirect?: RedirectionSideEffect; diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index 4dce1bc0a34..cfe9773e4e9 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -87,12 +87,12 @@ Edit.propTypes = { hasShow: PropTypes.bool, hasList: PropTypes.bool, id: PropTypes.any.isRequired, - resource: PropTypes.string.isRequired, - title: PropTypes.node, - successMessage: PropTypes.string, + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), onSuccess: PropTypes.func, onFailure: PropTypes.func, + resource: PropTypes.string.isRequired, + successMessage: PropTypes.string, + title: PropTypes.node, transform: PropTypes.func, undoable: PropTypes.bool, - mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), }; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 3bd03570405..3ff6a70f398 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -93,7 +93,8 @@ export const EditView = (props: EditViewProps) => { ? save : children.props.save, saving, - undoable: undoable || mutationMode === 'undoable', + undoable, + mutationMode, version, }) ) : ( @@ -134,6 +135,7 @@ EditView.propTypes = { defaultTitle: PropTypes.any, hasList: PropTypes.bool, hasShow: PropTypes.bool, + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), record: PropTypes.object, redirect: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), resource: PropTypes.string, @@ -145,6 +147,7 @@ EditView.propTypes = { setOnSuccess: PropTypes.func, setOnFailure: PropTypes.func, setTransform: PropTypes.func, + undoable: PropTypes.bool, }; EditView.defaultProps = { diff --git a/packages/ra-ui-materialui/src/form/SimpleForm.tsx b/packages/ra-ui-materialui/src/form/SimpleForm.tsx index e44a06b5913..9db78d3b524 100644 --- a/packages/ra-ui-materialui/src/form/SimpleForm.tsx +++ b/packages/ra-ui-materialui/src/form/SimpleForm.tsx @@ -11,6 +11,7 @@ import classnames from 'classnames'; import { FormWithRedirect, FormWithRedirectProps, + MutationMode, Record, RedirectionSideEffect, } from 'ra-core'; @@ -65,6 +66,7 @@ const SimpleForm: FC = props => ( SimpleForm.propTypes = { children: PropTypes.node, initialValues: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), // @ts-ignore record: PropTypes.object, redirect: PropTypes.oneOfType([ @@ -94,9 +96,11 @@ export interface SimpleFormProps component?: React.ComponentType; initialValues?: any; margin?: 'none' | 'normal' | 'dense'; + mutationMode?: MutationMode; resource?: string; submitOnEnter?: boolean; toolbar?: ReactElement; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; variant?: 'standard' | 'outlined' | 'filled'; } @@ -110,6 +114,7 @@ const SimpleFormView: FC = ({ handleSubmitWithRedirect, invalid, margin, + mutationMode, pristine, record, redirect, @@ -147,6 +152,7 @@ const SimpleFormView: FC = ({ handleSubmitWithRedirect, handleSubmit, invalid, + mutationMode, pristine, record, redirect, @@ -164,6 +170,7 @@ SimpleFormView.propTypes = { className: PropTypes.string, handleSubmit: PropTypes.func, // passed by react-final-form invalid: PropTypes.bool, + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), pristine: PropTypes.bool, // @ts-ignore record: PropTypes.object, @@ -185,14 +192,16 @@ export interface SimpleFormViewProps extends FormRenderProps { basePath?: string; className?: string; component?: React.ComponentType; - margin?: 'none' | 'normal' | 'dense'; handleSubmitWithRedirect?: (redirectTo: RedirectionSideEffect) => void; + margin?: 'none' | 'normal' | 'dense'; + mutationMode?: MutationMode; record?: Record; redirect?: RedirectionSideEffect; resource?: string; save?: () => void; saving?: boolean; toolbar?: ReactElement; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; variant?: 'standard' | 'outlined' | 'filled'; submitOnEnter?: boolean; diff --git a/packages/ra-ui-materialui/src/form/TabbedForm.tsx b/packages/ra-ui-materialui/src/form/TabbedForm.tsx index 4027bfadbe5..4e149e72d5b 100644 --- a/packages/ra-ui-materialui/src/form/TabbedForm.tsx +++ b/packages/ra-ui-materialui/src/form/TabbedForm.tsx @@ -16,6 +16,7 @@ import { escapePath, FormWithRedirect, FormWithRedirectProps, + MutationMode, Record, RedirectionSideEffect, } from 'ra-core'; @@ -103,6 +104,7 @@ const TabbedForm: FC = props => ( TabbedForm.propTypes = { children: PropTypes.node, initialValues: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), // @ts-ignore record: PropTypes.object, redirect: PropTypes.oneOfType([ @@ -130,6 +132,7 @@ export interface TabbedFormProps classes?: ClassesOverride; initialValues?: any; margin?: 'none' | 'normal' | 'dense'; + mutationMode?: MutationMode; record?: Record; redirect?: RedirectionSideEffect; resource?: string; @@ -145,6 +148,7 @@ export interface TabbedFormProps submitOnEnter?: boolean; tabs?: ReactElement; toolbar?: ReactElement; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; variant?: 'standard' | 'outlined' | 'filled'; warnWhenUnsavedChanges?: boolean; @@ -172,6 +176,7 @@ export const TabbedFormView: FC = props => { handleSubmit, handleSubmitWithRedirect, invalid, + mutationMode, pristine, record, redirect: defaultRedirect, @@ -242,6 +247,7 @@ export const TabbedFormView: FC = props => { handleSubmitWithRedirect, handleSubmit, invalid, + mutationMode, pristine, record, redirect: defaultRedirect, @@ -260,11 +266,12 @@ TabbedFormView.propTypes = { className: PropTypes.string, classes: PropTypes.object, defaultValue: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), // @deprecated - initialValues: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), handleSubmit: PropTypes.func, // passed by react-final-form + initialValues: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), invalid: PropTypes.bool, location: PropTypes.object, match: PropTypes.object, + mutationMode: PropTypes.oneOf(['pessimistic', 'optimistic', 'undoable']), pristine: PropTypes.bool, // @ts-ignore record: PropTypes.object, @@ -297,6 +304,7 @@ export interface TabbedFormViewProps extends FormRenderProps { classes?: ClassesOverride; className?: string; margin?: 'none' | 'normal' | 'dense'; + mutationMode?: MutationMode; handleSubmitWithRedirect?: (redirectTo: RedirectionSideEffect) => void; record?: Record; redirect?: RedirectionSideEffect; @@ -305,6 +313,7 @@ export interface TabbedFormViewProps extends FormRenderProps { saving?: boolean; tabs?: ReactElement; toolbar?: ReactElement; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; variant?: 'standard' | 'outlined' | 'filled'; submitOnEnter?: boolean; diff --git a/packages/ra-ui-materialui/src/form/Toolbar.tsx b/packages/ra-ui-materialui/src/form/Toolbar.tsx index fbe4aa32ae8..f1b8d67c552 100644 --- a/packages/ra-ui-materialui/src/form/Toolbar.tsx +++ b/packages/ra-ui-materialui/src/form/Toolbar.tsx @@ -12,10 +12,10 @@ import MuiToolbar from '@material-ui/core/Toolbar'; import withWidth from '@material-ui/core/withWidth'; import { makeStyles } from '@material-ui/core/styles'; import classnames from 'classnames'; +import { Record, RedirectionSideEffect, MutationMode } from 'ra-core'; +import { FormRenderProps } from 'react-final-form'; import { SaveButton, DeleteButton } from '../button'; -import { Record, RedirectionSideEffect } from 'ra-core'; -import { FormRenderProps } from 'react-final-form'; import { ClassesOverride } from '../types'; const useStyles = makeStyles( @@ -111,6 +111,7 @@ const Toolbar: FC = props => { saving, submitOnEnter, undoable, + mutationMode, width, ...rest } = props; @@ -152,6 +153,7 @@ const Toolbar: FC = props => { record={record} resource={resource} undoable={undoable} + mutationMode={mutationMode} /> )} @@ -200,6 +202,7 @@ export interface ToolbarProps { handleSubmitWithRedirect?: (redirect?: RedirectionSideEffect) => void; handleSubmit?: FormRenderProps['handleSubmit']; invalid?: boolean; + mutationMode?: MutationMode; pristine?: boolean; saving?: boolean; submitOnEnter?: boolean; @@ -207,6 +210,7 @@ export interface ToolbarProps { basePath?: string; record?: RecordType; resource?: string; + /** @deprecated use mutationMode: undoable instead */ undoable?: boolean; width?: string; } From 0b13897c689464c621bb9364b07a351b9fd2e6cc Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 29 Jan 2021 00:20:24 +0100 Subject: [PATCH 08/13] Document Edit component prop --- docs/CreateEdit.md | 87 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md index c0e7275d536..bd95b7ba3fb 100644 --- a/docs/CreateEdit.md +++ b/docs/CreateEdit.md @@ -98,7 +98,8 @@ You can customize the `` and `` components using the following pro * [`actions`](#actions) * [`aside`](#aside-component) * [`component`](#component) -* [`undoable`](#undoable) (`` only) +* [`undoable`](#undoable) (`` only) (deprecated) +* [`mutationMode`](#mutationMode) (`` only) * [`onSuccess`](#onsuccess) * [`onFailure`](#onfailure) * [`transform`](#transform) @@ -264,6 +265,8 @@ The default value for the `component` prop is `Card`. ### Undoable +**Note**: This prop is deprecated, use `mutationMode="undoable"` instead. + By default, the Save and Delete actions are undoable, i.e. react-admin only sends the related request to the data provider after a short delay, during which the user can cancel the action. This is part of the "optimistic rendering" strategy of react-admin ; it makes the user interactions more reactive. You can disable this behavior by setting `undoable={false}`. With that setting, clicking on the Delete button displays a confirmation dialog. Both the `Save` and `Delete` actions become blocking and delay the refresh of the screen until the data provider responds. @@ -312,6 +315,74 @@ const PostEdit = props => ( ); ``` +### `mutationMode` + +The `` view exposes two buttons, Save and Delete, which perform "mutations" (i.e. they alter the data). React-admin offers three modes for mutations. The mode determines when the side effects (redirection, notifications, etc.) are executed: + +- `pessimistic`: The mutation is passed to the dataProvider first. When the dataProvider returns successfully, the mutation is applied locally, and the side effects are executed. +- `optimistic`: The mutation is applied locally and the side effects are executed immediately. Then the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. +- `undoable` (default): The mutation is applied locally and the side effects are executed immediately. Then a notification is shown with an undo button. If the user clicks on undo, the mutation is never sent to the dataProvider, and the page is refreshed. Otherwise, after a 5 seconds delay, the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown. + +By default, pages using `` use the `undoable` mutation mode. This is part of the "optimistic rendering" strategy of react-admin ; it makes the user interactions more reactive. + +You can change this default by setting the `mutationMode` prop - and this affects both the Save and Delete buttons. For instance, to remove the ability to undo the changes, use the `optimistic` mode: + +```jsx +const PostEdit = props => ( + + // ... + +); +``` + +And to make both the Save and Delete actions blocking, and wait for the dataProvider response to continue, use the `pessimistic` mode: + +```jsx +const PostEdit = props => ( + + // ... + +); +``` + +**Tip**: When using any other mode than `undoable`, the `` displays a confirmation dialog before calling the dataProvider. + +**Tip**: If you want a confirmation dialog for the Delete button but don't mind undoable Edits, then pass a [custom toolbar](#toolbar) to the form, as follows: + +```jsx +import * as React from "react"; +import { + Toolbar, + SaveButton, + DeleteButton, + Edit, + SimpleForm, +} from 'react-admin'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}); + +const CustomToolbar = props => ( + + + + +); + +const PostEdit = props => ( + + }> + ... + + +); +``` + ### `onSuccess` By default, when the save action succeeds, react-admin shows a notification, and redirects to another page. You can override this behavior and pass custom side effects by providing a function as `onSuccess` prop: @@ -348,13 +419,13 @@ The default `onSuccess` function is: ```jsx // for the component: ({ data }) => { - notify('ra.notification.created', 'info', { smart_count: 1 }, undoable); + notify('ra.notification.created', 'info', { smart_count: 1 }); redirect('edit', basePath, data.id, data); } // for the component: ({ data }) => { - notify('ra.notification.updated', 'info', { smart_count: 1 }, undoable); + notify('ra.notification.updated', 'info', { smart_count: 1 }, mutationMode === 'undoable'); redirect('list', basePath, data.id, data); } ``` @@ -367,7 +438,7 @@ To learn more about built-in side effect hooks like `useNotify`, `useRedirect` a ### `onFailure` -By default, when the save action fails at the dataProvider level, react-admin shows an error notification. On an Edit page with `undoable` set to `true`, it refreshes the page, too. +By default, when the save action fails at the dataProvider level, react-admin shows an error notification. On an Edit page with `mutationMode` set to `undoable` or `optimistic`, it refreshes the page, too. You can override this behavior and pass custom side effects by providing a function as `onFailure` prop: @@ -409,7 +480,7 @@ The default `onOnFailure` function is: // for the component: (error) => { notify(typeof error === 'string' ? error : error.message || 'ra.notification.http_error', 'warning'); - if (undoable) { + if (mutationMode === 'undoable' || mutationMode === 'pessimistic') { refresh(); } } @@ -1988,10 +2059,6 @@ export const UserEdit = ({ permissions, ...props }) => Once the `dataProvider` returns successfully after save, users see a generic notification ("Element created" / "Element updated"). You can customize this message by passing a custom success side effect function as [the `` prop](#onsuccess): -By default, when saving with optimistic rendering, the `onSuccess` callback is called immediately. To change that, you need to pass `undoable={false}` to your `` or `` component. The `onSuccess` callback will then be called at the end of the dataProvider call. - -In addition, the saved object will not be available as an argument of the `onSuccess` callback method when `undoable` feature is enabled. - ```jsx import { Edit, useNotify, useRedirect } from 'react-admin'; @@ -2010,6 +2077,8 @@ const PostEdit = props => { } ``` +**Tip**: In `optimistic` and `undoable` mutation modes, react-admin calls the the `onSuccess` callback method with no argument. In `pessimistic` mode, it calls it with the response returned by the dataProvider as argument. + You can do the same for error notifications, e.g. to display a different message depending on the error returned by the `dataProvider`: ```jsx From f1a6fbb022b2681cb7d0ae1e47263df9ca979d58 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 29 Jan 2021 16:58:09 +0100 Subject: [PATCH 09/13] Document actions --- docs/Actions.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/Actions.md b/docs/Actions.md index d35410dda31..2af5a087c72 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -447,9 +447,9 @@ Fetching data is called a *side effect*, since it calls the outside world, and i ## Handling Side Effects In Other Hooks -But the other hooks presented in this chapter, starting with `useMutation`, don't expose the `dataProvider` Promise. To allow for side effects with these hooks, they all accept an additional `options` argument. It's an object with `onSuccess` and `onFailure` functions, that react-admin executes on success... or on failure. +The other hooks presented in this chapter, starting with `useQuery`, don't expose the `dataProvider` Promise. To allow for side effects with these hooks, they all accept an additional `options` argument. It's an object with `onSuccess` and `onFailure` functions, that react-admin executes on success... or on failure. -So the `` written with `useMutation` instead of `useDataProvider` can specify side effects as follows: +So an `` written with `useMutation` instead of `useDataProvider` can specify side effects as follows: ```jsx import * as React from "react"; @@ -478,13 +478,27 @@ const ApproveButton = ({ record }) => { ## Optimistic Rendering and Undo -In the previous example, after clicking on the "Approve" button, a loading spinner appears while the data provider is fetched. Then, users are redirected to the comments list. But in most cases, the server returns a success response, so the user waits for this response for nothing. +In the previous example, after clicking on the "Approve" button, a loading spinner appears while the data provider is fetched. Then, users are redirected to the comments list. But in most cases, the server returns a success response, so the user waits for this response for nothing. -For its own fetch actions, react-admin uses an approach called *optimistic rendering*. The idea is to handle the calls to the `dataProvider` on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the data provider are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. +This is called **pessimistic rendering**, as all users are forced to wait because of the (usually rare) possibilit yof server failure. -As a bonus, while the success notification is displayed, users have the ability to cancel the action *before* the data provider is even called. +An alternative mode for mutations is **optimistic rendering**. The idea is to handle the calls to the `dataProvider` on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the data provider are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. -You can benefit from optimistic rendering when you call the `useMutation` hook, too. You just need to pass `undoable: true` in the `options` parameter: +A third mutation mode is called **undoable**. It's like optimistic rendering, but with an added feature: after applying the changes and the side effects locally, react-admin *waits* for a few seconds before triggering the call to the data provider. During this delay, the end user sees an "undo" button that, when clicked, cancels the call to the data provider and refreshes the screen. + +Here is a quick recap of the three mutation modes: + +| | pessimistic | optimistic | undoable | +|-------------------|---------------------------|------------|-----------| +| dataProvider call | immediate | immediate | delayed | +| local changes | when dataProvider returns | immediate | immediate | +| side effects | when dataProvider returns | immediate | immediate | +| cancellable | no | no | yes | + + +For the Edit view, react-admin uses the undoable mode. For the Create view, react-admin needs to wait for the response to know the id of the resource to redirect to, so the mutation mode is pessimistic. + +You can benefit from optimistic and undoable modes when you call the `useMutation` hook, too. You just need to pass a `mutationMode` value in the `options` parameter: ```diff import * as React from "react"; @@ -500,7 +514,7 @@ const ApproveButton = ({ record }) => { payload: { id: record.id, data: { isApproved: true } }, }, { -+ undoable: true, ++ mutationMode: 'undoable', onSuccess: ({ data }) => { redirect('/comments'); - notify('Comment approved'); @@ -513,9 +527,9 @@ const ApproveButton = ({ record }) => { }; ``` -As you can see in this example, you need to tweak the notification for undoable actions: passing `true` as fourth parameter of `notify` displays the 'Undo' button in the notification. +As you can see in this example, you need to tweak the notification for undoable calls: passing `true` as fourth parameter of `notify` displays the 'Undo' button in the notification. -You can pass the `{ undoable: true }` options parameter to specialized hooks, too. they all accept an optional last argument with side effects. +You can pass the `mutationMode` option parameter to specialized hooks, too. They all accept an optional last argument with side effects. ```jsx import * as React from "react"; @@ -530,7 +544,7 @@ const ApproveButton = ({ record }) => { { isApproved: true }, record, { - undoable: true, + mutationMode: 'undoable', onSuccess: ({ data }) => { redirect('/comments'); notify('Comment approved', 'info', {}, true); @@ -565,7 +579,7 @@ const ApproveButton = ({ record }) => { { isApproved: true }, { + action: 'MY_CUSTOM_ACTION', - undoable: true, + mutationMode: 'undoable', onSuccess: ({ data }) => { redirect('/comments'); notify('Comment approved', 'info', {}, true); @@ -648,7 +662,7 @@ const ApproveButton = ({ record }) => { const redirect = useRedirect(); const payload = { id: record.id, data: { ...record, is_approved: true } }; const options = { - undoable: true, + mutationMode: 'undoable', onSuccess: ({ data }) => { notify('Comment approved', 'info', {}, true); redirect('/comments'); From a85ff494c2d9e691705ca39a963a061ea96aa6b9 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 29 Jan 2021 19:31:30 +0100 Subject: [PATCH 10/13] Fix markup in List documentation --- docs/List.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/List.md b/docs/List.md index 3a1a9e86532..ca1a79eccb9 100644 --- a/docs/List.md +++ b/docs/List.md @@ -749,6 +749,7 @@ When a List based component (eg: `PostList`) is passed to the `list` prop of a ` In order to enable the synchronization with the URL, you can set the `syncWithLocation` prop. For example, adding a `List` to an `Edit` page: +{% raw %} ```jsx const TagsEdit = (props) => ( <> @@ -765,6 +766,7 @@ const TagsEdit = (props) => ( ) ``` +{% endraw %} ### CSS API From f1b1df26a92ca75e1f582880d4bbb7c66e1d1746 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 29 Jan 2021 20:11:39 +0100 Subject: [PATCH 11/13] Add User mutationMode integration test --- .../ra-ui-materialui/src/detail/Edit.spec.js | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.js b/packages/ra-ui-materialui/src/detail/Edit.spec.js index f382a61f5e9..f8adfb8ff38 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.js +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.js @@ -1,7 +1,11 @@ import * as React from 'react'; import expect from 'expect'; -import { waitFor, fireEvent } from '@testing-library/react'; -import { renderWithRedux, DataProviderContext } from 'ra-core'; +import { waitFor, fireEvent, act } from '@testing-library/react'; +import { + renderWithRedux, + DataProviderContext, + undoableEventEmitter, +} from 'ra-core'; import { Edit } from './Edit'; @@ -72,6 +76,160 @@ describe('', () => { }); }); + describe('mutationMode prop', () => { + it('should be undoable by default', async () => { + const update = jest + .fn() + .mockImplementationOnce((_, { data }) => + Promise.resolve({ data }) + ); + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 1234, title: 'lorem' } }), + update, + }; + const onSuccess = jest.fn(); + const FakeForm = ({ record, save }) => ( + <> + {record.title} + + + ); + + const { queryByText, getByText, findByText } = renderWithRedux( + + + + + , + { admin: { resources: { foo: { data: {} } } } } + ); + await findByText('lorem'); + fireEvent.click(getByText('Update')); + // waitFor for the next tick + await act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + // changes applied locally + expect(queryByText('ipsum')).not.toBeNull(); + // side effects called right away + expect(onSuccess).toHaveBeenCalledTimes(1); + // dataProvider not called + expect(update).toHaveBeenCalledTimes(0); + act(() => { + undoableEventEmitter.emit('end', {}); + }); + // dataProvider called + expect(update).toHaveBeenCalledTimes(1); + }); + + it('should accept optimistic mode', async () => { + const update = jest + .fn() + .mockImplementationOnce((_, { data }) => + Promise.resolve({ data }) + ); + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 1234, title: 'lorem' } }), + update, + }; + const onSuccess = jest.fn(); + const FakeForm = ({ record, save }) => ( + <> + {record.title} + + + ); + + const { queryByText, getByText, findByText } = renderWithRedux( + + + + + , + { admin: { resources: { foo: { data: {} } } } } + ); + await findByText('lorem'); + fireEvent.click(getByText('Update')); + // waitFor for the next tick + await act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + // changes applied locally + expect(queryByText('ipsum')).not.toBeNull(); + // side effects called right away + expect(onSuccess).toHaveBeenCalledTimes(1); + // dataProvider called + expect(update).toHaveBeenCalledTimes(1); + }); + + it('should accept pessimistic mode', async () => { + let resolveUpdate; + const update = jest.fn().mockImplementationOnce((_, { data }) => + new Promise(resolve => { + resolveUpdate = resolve; + }).then(() => ({ data })) + ); + const dataProvider = { + getOne: () => + Promise.resolve({ data: { id: 1234, title: 'lorem' } }), + update, + }; + const onSuccess = jest.fn(); + const FakeForm = ({ record, save }) => ( + <> + {record.title} + + + ); + + const { queryByText, getByText, findByText } = renderWithRedux( + + + + + , + { admin: { resources: { foo: { data: {} } } } } + ); + await findByText('lorem'); + fireEvent.click(getByText('Update')); + // waitFor for the next tick + await act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + // changes not applied locally + expect(queryByText('ipsum')).toBeNull(); + // side effects not called right away + expect(onSuccess).toHaveBeenCalledTimes(0); + // dataProvider called + expect(update).toHaveBeenCalledTimes(1); + act(() => { + resolveUpdate(); + }); + // changes applied locally + await findByText('ipsum'); + // side effects applied + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + describe('onSuccess prop', () => { it('should allow to override the default success side effects', async () => { const dataProvider = { From 24a838426c03d15789f8dc8a136b1450c21c4b59 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 29 Jan 2021 20:12:05 +0100 Subject: [PATCH 12/13] Fix missing refresh in mutation mode in case of error --- packages/ra-core/src/controller/details/useEditController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ra-core/src/controller/details/useEditController.ts b/packages/ra-core/src/controller/details/useEditController.ts index 5004a1272e8..bbd75d0b77d 100644 --- a/packages/ra-core/src/controller/details/useEditController.ts +++ b/packages/ra-core/src/controller/details/useEditController.ts @@ -221,7 +221,10 @@ export const useEditController = ( : undefined, } ); - if (mutationMode === 'undoable') { + if ( + mutationMode === 'undoable' || + mutationMode === 'pessimistic' + ) { refresh(); } }, From 83ab49901a366b18e6048e081deb70fcd4197f48 Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Sat, 30 Jan 2021 07:51:34 +0100 Subject: [PATCH 13/13] Apply suggestions from code review Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- docs/Actions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Actions.md b/docs/Actions.md index 2af5a087c72..e6e4cc70947 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -480,11 +480,11 @@ const ApproveButton = ({ record }) => { In the previous example, after clicking on the "Approve" button, a loading spinner appears while the data provider is fetched. Then, users are redirected to the comments list. But in most cases, the server returns a success response, so the user waits for this response for nothing. -This is called **pessimistic rendering**, as all users are forced to wait because of the (usually rare) possibilit yof server failure. +This is called **pessimistic rendering**, as all users are forced to wait because of the (usually rare) possibility of server failure. -An alternative mode for mutations is **optimistic rendering**. The idea is to handle the calls to the `dataProvider` on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the data provider are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. +An alternative mode for mutations is **optimistic rendering**. The idea is to handle the calls to the `dataProvider` on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the `dataProvider` are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. -A third mutation mode is called **undoable**. It's like optimistic rendering, but with an added feature: after applying the changes and the side effects locally, react-admin *waits* for a few seconds before triggering the call to the data provider. During this delay, the end user sees an "undo" button that, when clicked, cancels the call to the data provider and refreshes the screen. +A third mutation mode is called **undoable**. It's like optimistic rendering, but with an added feature: after applying the changes and the side effects locally, react-admin *waits* for a few seconds before triggering the call to the `dataProvider`. During this delay, the end user sees an "undo" button that, when clicked, cancels the call to the `dataProvider` and refreshes the screen. Here is a quick recap of the three mutation modes: @@ -496,7 +496,7 @@ Here is a quick recap of the three mutation modes: | cancellable | no | no | yes | -For the Edit view, react-admin uses the undoable mode. For the Create view, react-admin needs to wait for the response to know the id of the resource to redirect to, so the mutation mode is pessimistic. +By default, react-admin uses the undoable mode for the Edit view. For the Create view, react-admin needs to wait for the response to know the id of the resource to redirect to, so the mutation mode is pessimistic. You can benefit from optimistic and undoable modes when you call the `useMutation` hook, too. You just need to pass a `mutationMode` value in the `options` parameter: