Skip to content

Commit 19e3c5c

Browse files
committed
Handle submission validation errors
1 parent a9c2833 commit 19e3c5c

22 files changed

+175
-58
lines changed

docs/CreateEdit.md

+42-1
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ export const PostCreate = (props) => (
710710
);
711711
```
712712

713-
**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):
713+
**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)):
714714

715715
* `save`: The function invoked when the form is submitted.
716716
* `saving`: A boolean indicating whether a save operation is ongoing.
@@ -1329,6 +1329,47 @@ export const UserCreate = (props) => (
13291329

13301330
**Important**: Note that asynchronous validators are not supported on the `<ArrayInput>` component due to a limitation of [react-final-form-arrays](https://github.com/final-form/react-final-form-arrays).
13311331

1332+
## Submission Validation
1333+
1334+
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:
1335+
1336+
{% raw %}
1337+
```jsx
1338+
import { useMutation } from 'react-admin';
1339+
1340+
export const UserCreate = (props) => {
1341+
const [mutate] = useMutation();
1342+
const save = useCallback(
1343+
async (values) => {
1344+
try {
1345+
await mutate({
1346+
type: 'create',
1347+
resource: 'users',
1348+
payload: { data: values },
1349+
}, { returnPromise: true });
1350+
} catch (error) {
1351+
if (error.body.errors) {
1352+
return error.body.errors;
1353+
}
1354+
}
1355+
},
1356+
[mutate],
1357+
);
1358+
1359+
return (
1360+
<Create undoable={false} {...props}>
1361+
<SimpleForm save={save}>
1362+
<TextInput label="First Name" source="firstName" />
1363+
<TextInput label="Age" source="age" />
1364+
</SimpleForm>
1365+
</Create>
1366+
);
1367+
};
1368+
```
1369+
{% endraw %}
1370+
1371+
**Tip**: The shape of the returned validation errors must correspond to the form: a key needs to match a `source` prop.
1372+
13321373
## Submit On Enter
13331374

13341375
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`:

packages/ra-core/src/dataProvider/Mutation.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface Props {
1616
event?: any,
1717
callTimePayload?: any,
1818
callTimeOptions?: any
19-
) => void,
19+
) => void | Promise<any>,
2020
params: ChildrenFuncParams
2121
) => JSX.Element;
2222
type: string;
@@ -35,6 +35,7 @@ interface Props {
3535
* @param {Object} options
3636
* @param {string} options.action Redux action type
3737
* @param {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider
38+
* @param {boolean} options.returnPromise Set to true to return the result promise of the mutation
3839
* @param {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() }
3940
* @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) }
4041
*

packages/ra-core/src/dataProvider/useMutation.spec.tsx

+40
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,44 @@ describe('useMutation', () => {
180180
expect(action.meta.resource).toBeUndefined();
181181
expect(dataProvider.mytype).toHaveBeenCalledWith(myPayload);
182182
});
183+
184+
it('should return a promise to dispatch a fetch action when returnPromise option is set and the mutation callback is triggered', async () => {
185+
const dataProvider = {
186+
mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })),
187+
};
188+
189+
let promise = null;
190+
const myPayload = {};
191+
const { getByText, dispatch } = renderWithRedux(
192+
<DataProviderContext.Provider value={dataProvider}>
193+
<Mutation
194+
type="mytype"
195+
resource="myresource"
196+
payload={myPayload}
197+
options={{ returnPromise: true }}
198+
>
199+
{(mutate, { loading }) => (
200+
<button
201+
className={loading ? 'loading' : 'idle'}
202+
onClick={() => (promise = mutate())}
203+
>
204+
Hello
205+
</button>
206+
)}
207+
</Mutation>
208+
</DataProviderContext.Provider>
209+
);
210+
const buttonElement = getByText('Hello');
211+
fireEvent.click(buttonElement);
212+
const action = dispatch.mock.calls[0][0];
213+
expect(action.type).toEqual('CUSTOM_FETCH');
214+
expect(action.payload).toEqual(myPayload);
215+
expect(action.meta.resource).toEqual('myresource');
216+
await waitFor(() => {
217+
expect(buttonElement.className).toEqual('idle');
218+
});
219+
expect(promise).toBeInstanceOf(Promise);
220+
const result = await promise;
221+
expect(result).toMatchObject({ data: { foo: 'bar' } });
222+
});
183223
});

packages/ra-core/src/dataProvider/useMutation.ts

+23-4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDecl
3131
* @param {Object} options
3232
* @param {string} options.action Redux action type
3333
* @param {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider
34+
* @param {boolean} options.returnPromise Set to true to return the result promise of the mutation
3435
* @param {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() }
3536
* @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) }
3637
* @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
5253
* - {Object} options
5354
* - {string} options.action Redux action type
5455
* - {boolean} options.undoable Set to true to run the mutation locally before calling the dataProvider
56+
* - {boolean} options.returnPromise Set to true to return the result promise of the mutation
5557
* - {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() }
5658
* - {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) }
5759
* - {boolean} withDeclarativeSideEffectsSupport Set to true to support legacy side effects e.g. { onSuccess: { refresh: true } }
@@ -140,7 +142,7 @@ const useMutation = (
140142
(
141143
callTimeQuery?: Mutation | Event,
142144
callTimeOptions?: MutationOptions
143-
): void => {
145+
): void | Promise<any> => {
144146
const finalDataProvider = hasDeclarativeSideEffectsSupport(
145147
options,
146148
callTimeOptions
@@ -156,21 +158,27 @@ const useMutation = (
156158

157159
setState(prevState => ({ ...prevState, loading: true }));
158160

159-
finalDataProvider[params.type]
161+
const returnPromise = params.options.returnPromise;
162+
163+
const promise = finalDataProvider[params.type]
160164
.apply(
161165
finalDataProvider,
162166
typeof params.resource !== 'undefined'
163167
? [params.resource, params.payload, params.options]
164168
: [params.payload, params.options]
165169
)
166-
.then(({ data, total }) => {
170+
.then(response => {
171+
const { data, total } = response;
167172
setState({
168173
data,
169174
error: null,
170175
loaded: true,
171176
loading: false,
172177
total,
173178
});
179+
if (returnPromise) {
180+
return response;
181+
}
174182
})
175183
.catch(errorFromResponse => {
176184
setState({
@@ -180,7 +188,14 @@ const useMutation = (
180188
loading: false,
181189
total: null,
182190
});
191+
if (returnPromise) {
192+
throw errorFromResponse;
193+
}
183194
});
195+
196+
if (returnPromise) {
197+
return promise;
198+
}
184199
},
185200
[
186201
// deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055
@@ -204,13 +219,17 @@ export interface Mutation {
204219
export interface MutationOptions {
205220
action?: string;
206221
undoable?: boolean;
222+
returnPromise?: boolean;
207223
onSuccess?: (response: any) => any | Object;
208224
onFailure?: (error?: any) => any | Object;
209225
withDeclarativeSideEffectsSupport?: boolean;
210226
}
211227

212228
export type UseMutationValue = [
213-
(query?: Partial<Mutation>, options?: Partial<MutationOptions>) => void,
229+
(
230+
query?: Partial<Mutation>,
231+
options?: Partial<MutationOptions>
232+
) => void | Promise<any>,
214233
{
215234
data?: any;
216235
total?: number;

packages/ra-core/src/form/FormWithRedirect.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,9 @@ const FormWithRedirect: FC<FormWithRedirectProps> = ({
142142
finalInitialValues,
143143
values
144144
);
145-
onSave.current(sanitizedValues, finalRedirect);
145+
return onSave.current(sanitizedValues, finalRedirect);
146146
} else {
147-
onSave.current(values, finalRedirect);
147+
return onSave.current(values, finalRedirect);
148148
}
149149
};
150150

packages/ra-core/src/form/useInitializeFormWithRecord.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ const useInitializeFormWithRecord = record => {
1919
// Disable this option when re-initializing the form because in this case, it should reset the dirty state of all fields
2020
// We do need to keep this option for dynamically added inputs though which is why it is kept at the form level
2121
form.setConfig('keepDirtyOnReinitialize', false);
22-
// Ignored until next version of final-form is released. See https://github.com/final-form/final-form/pull/376
23-
// @ts-ignore
24-
form.restart(initialValuesMergedWithRecord);
25-
form.setConfig('keepDirtyOnReinitialize', true);
22+
// Since the submit function returns a promise, use setTimeout to prevent the error "Cannot reset() in onSubmit()" in final-form
23+
// 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)
24+
setTimeout(() => {
25+
// Ignored until next version of final-form is released. See https://github.com/final-form/final-form/pull/376
26+
// @ts-ignore
27+
form.restart(initialValuesMergedWithRecord);
28+
form.setConfig('keepDirtyOnReinitialize', true);
29+
});
2630
}, [form, JSON.stringify(record)]); // eslint-disable-line react-hooks/exhaustive-deps
2731
};
2832

packages/ra-ui-materialui/src/detail/CreateView.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ export const CreateView = (props: CreateViewProps) => {
6666
? redirect
6767
: children.props.redirect,
6868
resource,
69-
save,
69+
save:
70+
typeof children.props.save === 'undefined'
71+
? save
72+
: children.props.save,
7073
saving,
7174
version,
7275
})}
@@ -76,7 +79,10 @@ export const CreateView = (props: CreateViewProps) => {
7679
basePath,
7780
record,
7881
resource,
79-
save,
82+
save:
83+
typeof children.props.save === 'undefined'
84+
? save
85+
: children.props.save,
8086
saving,
8187
version,
8288
})}

packages/ra-ui-materialui/src/detail/EditView.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ export const EditView = (props: EditViewProps) => {
8787
? redirect
8888
: children.props.redirect,
8989
resource,
90-
save,
90+
save:
91+
typeof children.props.save === 'undefined'
92+
? save
93+
: children.props.save,
9194
saving,
9295
undoable,
9396
version,
@@ -102,7 +105,10 @@ export const EditView = (props: EditViewProps) => {
102105
record,
103106
resource,
104107
version,
105-
save,
108+
save:
109+
typeof children.props.save === 'undefined'
110+
? save
111+
: children.props.save,
106112
saving,
107113
})}
108114
</div>

packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const AutocompleteArrayInput: FunctionComponent<
167167
id,
168168
input,
169169
isRequired,
170-
meta: { touched, error },
170+
meta: { touched, error, submitError },
171171
} = useInput({
172172
format,
173173
id: idOverride,
@@ -427,7 +427,7 @@ const AutocompleteArrayInput: FunctionComponent<
427427
},
428428
onFocus,
429429
}}
430-
error={!!(touched && error)}
430+
error={!!(touched && (error || submitError))}
431431
label={
432432
<FieldTitle
433433
label={label}
@@ -448,7 +448,7 @@ const AutocompleteArrayInput: FunctionComponent<
448448
helperText={
449449
<InputHelperText
450450
touched={touched}
451-
error={error}
451+
error={error || submitError}
452452
helperText={helperText}
453453
/>
454454
}

packages/ra-ui-materialui/src/input/AutocompleteInput.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ const AutocompleteInput: FunctionComponent<AutocompleteInputProps> = props => {
184184
id,
185185
input,
186186
isRequired,
187-
meta: { touched, error },
187+
meta: { touched, error, submitError },
188188
} = useInput({
189189
format,
190190
id: idOverride,
@@ -488,7 +488,7 @@ const AutocompleteInput: FunctionComponent<AutocompleteInputProps> = props => {
488488
onFocus,
489489
...InputPropsWithoutEndAdornment,
490490
}}
491-
error={!!(touched && error)}
491+
error={!!(touched && (error || submitError))}
492492
label={
493493
<FieldTitle
494494
label={label}
@@ -509,7 +509,7 @@ const AutocompleteInput: FunctionComponent<AutocompleteInputProps> = props => {
509509
helperText={
510510
<InputHelperText
511511
touched={touched}
512-
error={error}
512+
error={error || submitError}
513513
helperText={helperText}
514514
/>
515515
}

packages/ra-ui-materialui/src/input/BooleanInput.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const BooleanInput: FunctionComponent<
3434
id,
3535
input: { onChange: finalFormOnChange, type, value, ...inputProps },
3636
isRequired,
37-
meta: { error, touched },
37+
meta: { error, submitError, touched },
3838
} = useInput({
3939
format,
4040
onBlur,
@@ -77,10 +77,10 @@ const BooleanInput: FunctionComponent<
7777
/>
7878
}
7979
/>
80-
<FormHelperText error={!!error}>
80+
<FormHelperText error={!!(error || submitError)}>
8181
<InputHelperText
8282
touched={touched}
83-
error={error}
83+
error={error || submitError}
8484
helperText={helperText}
8585
/>
8686
</FormHelperText>

packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ const CheckboxGroupInput: FunctionComponent<
144144
id,
145145
input: { onChange: finalFormOnChange, onBlur: finalFormOnBlur, value },
146146
isRequired,
147-
meta: { error, touched },
147+
meta: { error, submitError, touched },
148148
} = useInput({
149149
format,
150150
onBlur,
@@ -195,7 +195,7 @@ const CheckboxGroupInput: FunctionComponent<
195195
<FormControl
196196
component="fieldset"
197197
margin={margin}
198-
error={touched && !!error}
198+
error={touched && !!(error || submitError)}
199199
className={classnames(classes.root, className)}
200200
{...sanitizeRestProps(rest)}
201201
>
@@ -225,7 +225,7 @@ const CheckboxGroupInput: FunctionComponent<
225225
<FormHelperText>
226226
<InputHelperText
227227
touched={touched}
228-
error={error}
228+
error={error || submitError}
229229
helperText={helperText}
230230
/>
231231
</FormHelperText>

0 commit comments

Comments
 (0)