diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md index 4a0c031d0d4..c0e7275d536 100644 --- a/docs/CreateEdit.md +++ b/docs/CreateEdit.md @@ -710,7 +710,7 @@ export const PostCreate = (props) => ( ); ``` -**Tip**: `Create` and `Edit` inject more props to their child. So `SimpleForm` also expects these props to be set (but you shouldn't set them yourself): +**Tip**: `Create` and `Edit` inject more props to their child. So `SimpleForm` also expects these props to be set (you should set them yourself only in particular cases like the [submission validation](#submission-validation)): * `save`: The function invoked when the form is submitted. * `saving`: A boolean indicating whether a save operation is ongoing. @@ -1329,6 +1329,47 @@ export const UserCreate = (props) => ( **Important**: Note that asynchronous validators are not supported on the `` component due to a limitation of [react-final-form-arrays](https://github.com/final-form/react-final-form-arrays). +## Submission Validation + +The form can be validated by the server after its submission. In order to display the validation errors, a custom `save` function needs to be used: + +{% raw %} +```jsx +import { useMutation } from 'react-admin'; + +export const UserCreate = (props) => { + const [mutate] = useMutation(); + const save = useCallback( + async (values) => { + try { + await mutate({ + type: 'create', + resource: 'users', + payload: { data: values }, + }, { returnPromise: true }); + } catch (error) { + if (error.body.errors) { + return error.body.errors; + } + } + }, + [mutate], + ); + + return ( + + + + + + + ); +}; +``` +{% endraw %} + +**Tip**: The shape of the returned validation errors must correspond to the form: a key needs to match a `source` prop. + ## Submit On Enter By default, pressing `ENTER` in any of the form fields submits the form - this is the expected behavior in most cases. However, some of your custom input components (e.g. Google Maps widget) may have special handlers for the `ENTER` key. In that case, to disable the automated form submission on enter, set the `submitOnEnter` prop of the form component to `false`: diff --git a/packages/ra-core/src/dataProvider/Mutation.tsx b/packages/ra-core/src/dataProvider/Mutation.tsx index 17154364ce1..dd0f749278a 100644 --- a/packages/ra-core/src/dataProvider/Mutation.tsx +++ b/packages/ra-core/src/dataProvider/Mutation.tsx @@ -16,7 +16,7 @@ interface Props { event?: any, callTimePayload?: any, callTimeOptions?: any - ) => void, + ) => void | Promise, params: ChildrenFuncParams ) => JSX.Element; type: string; @@ -35,6 +35,7 @@ interface Props { * @param {Object} options * @param {string} options.action Redux action type * @param {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider + * @param {boolean} options.returnPromise Set to true to return the result promise of the mutation * @param {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() } * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * diff --git a/packages/ra-core/src/dataProvider/useMutation.spec.tsx b/packages/ra-core/src/dataProvider/useMutation.spec.tsx index 579ce8e2847..ade28295dac 100644 --- a/packages/ra-core/src/dataProvider/useMutation.spec.tsx +++ b/packages/ra-core/src/dataProvider/useMutation.spec.tsx @@ -180,4 +180,44 @@ describe('useMutation', () => { expect(action.meta.resource).toBeUndefined(); expect(dataProvider.mytype).toHaveBeenCalledWith(myPayload); }); + + it('should return a promise to dispatch a fetch action when returnPromise option is set and the mutation callback is triggered', async () => { + const dataProvider = { + mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), + }; + + let promise = null; + const myPayload = {}; + const { getByText, dispatch } = renderWithRedux( + + + {(mutate, { loading }) => ( + + )} + + + ); + const buttonElement = getByText('Hello'); + fireEvent.click(buttonElement); + const action = dispatch.mock.calls[0][0]; + expect(action.type).toEqual('CUSTOM_FETCH'); + expect(action.payload).toEqual(myPayload); + expect(action.meta.resource).toEqual('myresource'); + await waitFor(() => { + expect(buttonElement.className).toEqual('idle'); + }); + expect(promise).toBeInstanceOf(Promise); + const result = await promise; + expect(result).toMatchObject({ data: { foo: 'bar' } }); + }); }); diff --git a/packages/ra-core/src/dataProvider/useMutation.ts b/packages/ra-core/src/dataProvider/useMutation.ts index c10e3bf2746..d465a0a8b8f 100644 --- a/packages/ra-core/src/dataProvider/useMutation.ts +++ b/packages/ra-core/src/dataProvider/useMutation.ts @@ -31,6 +31,7 @@ import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDecl * @param {Object} options * @param {string} options.action Redux action type * @param {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider + * @param {boolean} options.returnPromise Set to true to return the result promise of the mutation * @param {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() } * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * @param {boolean} options.withDeclarativeSideEffectsSupport Set to true to support legacy side effects e.g. { onSuccess: { refresh: true } } @@ -52,6 +53,7 @@ import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDecl * - {Object} options * - {string} options.action Redux action type * - {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider + * - {boolean} options.returnPromise Set to true to return the result promise of the mutation * - {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() } * - {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * - {boolean} withDeclarativeSideEffectsSupport Set to true to support legacy side effects e.g. { onSuccess: { refresh: true } } @@ -140,7 +142,7 @@ const useMutation = ( ( callTimeQuery?: Mutation | Event, callTimeOptions?: MutationOptions - ): void => { + ): void | Promise => { const finalDataProvider = hasDeclarativeSideEffectsSupport( options, callTimeOptions @@ -156,14 +158,17 @@ const useMutation = ( setState(prevState => ({ ...prevState, loading: true })); - finalDataProvider[params.type] + const returnPromise = params.options.returnPromise; + + const promise = finalDataProvider[params.type] .apply( finalDataProvider, typeof params.resource !== 'undefined' ? [params.resource, params.payload, params.options] : [params.payload, params.options] ) - .then(({ data, total }) => { + .then(response => { + const { data, total } = response; setState({ data, error: null, @@ -171,6 +176,9 @@ const useMutation = ( loading: false, total, }); + if (returnPromise) { + return response; + } }) .catch(errorFromResponse => { setState({ @@ -180,7 +188,14 @@ const useMutation = ( loading: false, total: null, }); + if (returnPromise) { + throw errorFromResponse; + } }); + + if (returnPromise) { + return promise; + } }, [ // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 @@ -204,13 +219,17 @@ export interface Mutation { export interface MutationOptions { action?: string; undoable?: boolean; + returnPromise?: boolean; onSuccess?: (response: any) => any | Object; onFailure?: (error?: any) => any | Object; withDeclarativeSideEffectsSupport?: boolean; } export type UseMutationValue = [ - (query?: Partial, options?: Partial) => void, + ( + query?: Partial, + options?: Partial + ) => void | Promise, { data?: any; total?: number; diff --git a/packages/ra-core/src/form/FormWithRedirect.tsx b/packages/ra-core/src/form/FormWithRedirect.tsx index 622763f0da7..b6232bfad1f 100644 --- a/packages/ra-core/src/form/FormWithRedirect.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.tsx @@ -142,9 +142,9 @@ const FormWithRedirect: FC = ({ finalInitialValues, values ); - onSave.current(sanitizedValues, finalRedirect); + return onSave.current(sanitizedValues, finalRedirect); } else { - onSave.current(values, finalRedirect); + return onSave.current(values, finalRedirect); } }; diff --git a/packages/ra-core/src/form/useInitializeFormWithRecord.ts b/packages/ra-core/src/form/useInitializeFormWithRecord.ts index 6084cfb5562..9671a770c94 100644 --- a/packages/ra-core/src/form/useInitializeFormWithRecord.ts +++ b/packages/ra-core/src/form/useInitializeFormWithRecord.ts @@ -19,10 +19,14 @@ const useInitializeFormWithRecord = record => { // Disable this option when re-initializing the form because in this case, it should reset the dirty state of all fields // We do need to keep this option for dynamically added inputs though which is why it is kept at the form level form.setConfig('keepDirtyOnReinitialize', false); - // Ignored until next version of final-form is released. See https://github.com/final-form/final-form/pull/376 - // @ts-ignore - form.restart(initialValuesMergedWithRecord); - form.setConfig('keepDirtyOnReinitialize', true); + // Since the submit function returns a promise, use setTimeout to prevent the error "Cannot reset() in onSubmit()" in final-form + // It will not be necessary anymore when the next version of final-form will be released (see https://github.com/final-form/final-form/pull/363) + setTimeout(() => { + // Ignored until next version of final-form is released. See https://github.com/final-form/final-form/pull/376 + // @ts-ignore + form.restart(initialValuesMergedWithRecord); + form.setConfig('keepDirtyOnReinitialize', true); + }); }, [form, JSON.stringify(record)]); // eslint-disable-line react-hooks/exhaustive-deps }; diff --git a/packages/ra-ui-materialui/src/detail/CreateView.tsx b/packages/ra-ui-materialui/src/detail/CreateView.tsx index 9949d286eaa..94bc0354533 100644 --- a/packages/ra-ui-materialui/src/detail/CreateView.tsx +++ b/packages/ra-ui-materialui/src/detail/CreateView.tsx @@ -66,7 +66,10 @@ export const CreateView = (props: CreateViewProps) => { ? redirect : children.props.redirect, resource, - save, + save: + typeof children.props.save === 'undefined' + ? save + : children.props.save, saving, version, })} @@ -76,7 +79,10 @@ export const CreateView = (props: CreateViewProps) => { basePath, record, resource, - save, + save: + typeof children.props.save === 'undefined' + ? save + : children.props.save, saving, version, })} diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index e3d350dc461..f7e135af529 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -87,7 +87,10 @@ export const EditView = (props: EditViewProps) => { ? redirect : children.props.redirect, resource, - save, + save: + typeof children.props.save === 'undefined' + ? save + : children.props.save, saving, undoable, version, @@ -102,7 +105,10 @@ export const EditView = (props: EditViewProps) => { record, resource, version, - save, + save: + typeof children.props.save === 'undefined' + ? save + : children.props.save, saving, })} diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index 15bf3bc5067..a27047c6c1d 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -167,7 +167,7 @@ const AutocompleteArrayInput: FunctionComponent< id, input, isRequired, - meta: { touched, error }, + meta: { touched, error, submitError }, } = useInput({ format, id: idOverride, @@ -427,7 +427,7 @@ const AutocompleteArrayInput: FunctionComponent< }, onFocus, }} - error={!!(touched && error)} + error={!!(touched && (error || submitError))} label={ } diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index b3035ecf803..39ad9781aef 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -184,7 +184,7 @@ const AutocompleteInput: FunctionComponent = props => { id, input, isRequired, - meta: { touched, error }, + meta: { touched, error, submitError }, } = useInput({ format, id: idOverride, @@ -488,7 +488,7 @@ const AutocompleteInput: FunctionComponent = props => { onFocus, ...InputPropsWithoutEndAdornment, }} - error={!!(touched && error)} + error={!!(touched && (error || submitError))} label={ = props => { helperText={ } diff --git a/packages/ra-ui-materialui/src/input/BooleanInput.tsx b/packages/ra-ui-materialui/src/input/BooleanInput.tsx index 9a6ecd8d056..3c4ac035741 100644 --- a/packages/ra-ui-materialui/src/input/BooleanInput.tsx +++ b/packages/ra-ui-materialui/src/input/BooleanInput.tsx @@ -34,7 +34,7 @@ const BooleanInput: FunctionComponent< id, input: { onChange: finalFormOnChange, type, value, ...inputProps }, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -77,10 +77,10 @@ const BooleanInput: FunctionComponent< /> } /> - + diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx index 4fc6b9a145f..c13e2c4594d 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx @@ -144,7 +144,7 @@ const CheckboxGroupInput: FunctionComponent< id, input: { onChange: finalFormOnChange, onBlur: finalFormOnBlur, value }, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -195,7 +195,7 @@ const CheckboxGroupInput: FunctionComponent< @@ -225,7 +225,7 @@ const CheckboxGroupInput: FunctionComponent< diff --git a/packages/ra-ui-materialui/src/input/DateInput.tsx b/packages/ra-ui-materialui/src/input/DateInput.tsx index b656a72273b..e7c49229032 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.tsx @@ -66,7 +66,7 @@ const DateInput: FunctionComponent< id, input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -86,11 +86,11 @@ const DateInput: FunctionComponent< variant={variant} margin={margin} type="date" - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ } diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx index c8b850419c3..c3c8a2f60f6 100644 --- a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx @@ -87,7 +87,7 @@ const DateTimeInput: FunctionComponent< id, input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -107,11 +107,11 @@ const DateTimeInput: FunctionComponent< {...input} variant={variant} margin={margin} - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ } diff --git a/packages/ra-ui-materialui/src/input/FileInput.tsx b/packages/ra-ui-materialui/src/input/FileInput.tsx index a2272b25d52..24f55ea63bf 100644 --- a/packages/ra-ui-materialui/src/input/FileInput.tsx +++ b/packages/ra-ui-materialui/src/input/FileInput.tsx @@ -128,7 +128,7 @@ const FileInput: FunctionComponent< validate, ...rest, }); - const { touched, error } = meta; + const { touched, error, submitError } = meta; const files = value ? (Array.isArray(value) ? value : [value]) : []; const onDrop = (newFiles, rejectedFiles, event) => { @@ -209,7 +209,7 @@ const FileInput: FunctionComponent< diff --git a/packages/ra-ui-materialui/src/input/Labeled.tsx b/packages/ra-ui-materialui/src/input/Labeled.tsx index d2b6c968a66..f4f4b9a2aef 100644 --- a/packages/ra-ui-materialui/src/input/Labeled.tsx +++ b/packages/ra-ui-materialui/src/input/Labeled.tsx @@ -87,7 +87,7 @@ const Labeled: FunctionComponent = props => { diff --git a/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx b/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx index 6ff19509166..ca21a76fac7 100644 --- a/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx +++ b/packages/ra-ui-materialui/src/input/NullableBooleanInput.tsx @@ -65,7 +65,7 @@ const NullableBooleanInput: FunctionComponent = props id, input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -91,11 +91,11 @@ const NullableBooleanInput: FunctionComponent = props isRequired={isRequired} /> } - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ } diff --git a/packages/ra-ui-materialui/src/input/NumberInput.tsx b/packages/ra-ui-materialui/src/input/NumberInput.tsx index e2a7ff9b36f..9445c488fc8 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.tsx @@ -49,7 +49,7 @@ const NumberInput: FunctionComponent = ({ id, input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -70,11 +70,11 @@ const NumberInput: FunctionComponent = ({ id={id} {...input} variant={variant} - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ } diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx index 12285edce9e..6778df26d48 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx @@ -138,7 +138,7 @@ const RadioButtonGroupInput: FunctionComponent< ...rest, }); - const { error, touched } = meta; + const { error, submitError, touched } = meta; if (loading) { return ( @@ -160,7 +160,7 @@ const RadioButtonGroupInput: FunctionComponent< @@ -188,7 +188,7 @@ const RadioButtonGroupInput: FunctionComponent< diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index 1e766dbc1ed..aab4272f2f3 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -181,7 +181,7 @@ const SelectArrayInput: FunctionComponent = props => { const { input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -230,14 +230,14 @@ const SelectArrayInput: FunctionComponent = props => { = props => { autoWidth labelId={`${label}-outlined-label`} multiple - error={!!(touched && error)} + error={!!(touched && (error || submitError))} renderValue={(selected: any[]) => (
{selected @@ -276,10 +276,10 @@ const SelectArrayInput: FunctionComponent = props => { > {choices.map(renderMenuItem)} - + diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 12709d9713a..9cab4b70745 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -198,7 +198,7 @@ const SelectInput: FunctionComponent< ...rest, }); - const { touched, error } = meta; + const { touched, error, submitError } = meta; const renderEmptyItemOption = useCallback(() => { return React.isValidElement(emptyText) @@ -247,11 +247,11 @@ const SelectInput: FunctionComponent< } className={`${classes.input} ${className}`} clearAlwaysVisible - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ } diff --git a/packages/ra-ui-materialui/src/input/TextInput.tsx b/packages/ra-ui-materialui/src/input/TextInput.tsx index c2ec6be7522..99e62b2f41b 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.tsx @@ -43,7 +43,7 @@ const TextInput: FunctionComponent = ({ id, input, isRequired, - meta: { error, touched }, + meta: { error, submitError, touched }, } = useInput({ format, onBlur, @@ -72,11 +72,11 @@ const TextInput: FunctionComponent = ({ /> ) } - error={!!(touched && error)} + error={!!(touched && (error || submitError))} helperText={ }