diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md index c277a8b7399..230eb7aab69 100644 --- a/docs/CreateEdit.md +++ b/docs/CreateEdit.md @@ -1092,6 +1092,38 @@ export const UserCreate = (props) => ( **Tip**: The props you pass to `` and `` are passed to the [
](https://final-form.org/docs/react-final-form/api/Form) of `react-final-form`. +**Tip**: The `validate` function can return a promise for asynchronous validation. For instance: + +```jsx +const validateUserCreation = async (values) => { + const errors = {}; + if (!values.firstName) { + errors.firstName = ['The firstName is required']; + } + if (!values.age) { + errors.age = ['The age is required']; + } else if (values.age < 18) { + errors.age = ['Must be over 18']; + } + + const isEmailUnique = await checkEmailIsUnique(values.userName); + if (!isEmailUnique) { + errors.email = ['Email already used']; + } + return errors +}; + +export const UserCreate = (props) => ( + + + + + + + +); +``` + ### Per Input Validation: Built-in Field Validators Alternatively, you can specify a `validate` prop directly in `` components, taking either a function or an array of functions. React-admin already bundles a few validator functions, that you can just require, and use as input-level validators: @@ -1257,6 +1289,39 @@ export const ProductEdit = ({ ...props }) => ( **Tip**: You can use *both* Form validation and input validation. +**Tip**: The custom validator function can return a promise for asynchronous validation. For instance: + +```jsx +const validateEmailUnicity = async (value) => { + const isEmailUnique = await checkEmailIsUnique(value); + if (!isEmailUnique) { + return 'Email already used'; + + // You can return a translation key as well + return 'myroot.validation.email_already_used'; + + // Or even an object just like the other validators + return { message: 'myroot.validation.email_already_used', args: { email: value } } + + } + + return errors +}; + +const emailValidators = [required(), validateEmailUnicity]; + +export const UserCreate = (props) => ( + + + ... + + ... + + +); +``` + +**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). ## 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/docs/Inputs.md b/docs/Inputs.md index 10df7a5affc..28d71ad7066 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -1008,7 +1008,9 @@ import { ArrayInput, SimpleFormIterator, DateInput, TextInput, FormDataConsumer ``` -`` also accepts the [common input props](./Inputs.md#common-input-props) (except `format` and `parse`). +`` also accepts the [common input props](./Inputs.md#common-input-props) (except `format` and `parse`). + +**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). ### `` diff --git a/examples/simple/src/users/UserCreate.js b/examples/simple/src/users/UserCreate.js index ac22e751950..b959c1c6936 100644 --- a/examples/simple/src/users/UserCreate.js +++ b/examples/simple/src/users/UserCreate.js @@ -38,6 +38,11 @@ const UserEditToolbar = ({ ); +const isValidName = async value => + new Promise(resolve => + setTimeout(resolve(value === 'Admin' ? "Can't be Admin" : undefined)) + ); + const UserCreate = ({ permissions, ...props }) => ( }> ( source="name" defaultValue="Slim Shady" autoFocus - validate={required()} + validate={[required(), isValidName]} /> {permissions === 'admin' && ( diff --git a/packages/ra-core/src/form/validate.spec.ts b/packages/ra-core/src/form/validate.spec.ts index a1b17d74b11..6f772942af1 100644 --- a/packages/ra-core/src/form/validate.spec.ts +++ b/packages/ra-core/src/form/validate.spec.ts @@ -14,45 +14,99 @@ import { } from './validate'; describe('Validators', () => { - const test = (validator, inputs, message) => - expect( - inputs - .map(input => validator(input, null)) - .map(error => (error && error.message ? error.message : error)) - ).toEqual(Array(...Array(inputs.length)).map(() => message)); + const test = async (validator, inputs, message) => { + const validationResults = await Promise.all( + inputs.map(input => validator(input, null)) + ).then(results => + results.map(error => + error && error.message ? error.message : error + ) + ); + + expect(validationResults).toEqual( + Array(...Array(inputs.length)).map(() => message) + ); + }; describe('composeValidators', () => { - it('Correctly composes validators passed as an array', () => { - test( - composeValidators([required(), minLength(5)]), + const asyncSuccessfullValidator = async => + new Promise(resolve => resolve()); + const asyncFailedValidator = async => + new Promise(resolve => resolve('async')); + + it('Correctly composes validators passed as an array', async () => { + await test( + composeValidators([ + required(), + minLength(5), + asyncSuccessfullValidator, + ]), [''], 'ra.validation.required' ); - test( - composeValidators([required(), minLength(5)]), + await test( + composeValidators([ + required(), + asyncSuccessfullValidator, + minLength(5), + ]), ['abcd'], 'ra.validation.minLength' ); - test( - composeValidators([required(), minLength(5)]), + await test( + composeValidators([ + required(), + asyncFailedValidator, + minLength(5), + ]), + ['abcde'], + 'async' + ); + await test( + composeValidators([ + required(), + minLength(5), + asyncSuccessfullValidator, + ]), ['abcde'], undefined ); }); - it('Correctly composes validators passed as many arguments', () => { - test( - composeValidators(required(), minLength(5)), + it('Correctly composes validators passed as many arguments', async () => { + await test( + composeValidators( + required(), + minLength(5), + asyncSuccessfullValidator + ), [''], 'ra.validation.required' ); - test( - composeValidators(required(), minLength(5)), + await test( + composeValidators( + required(), + asyncSuccessfullValidator, + minLength(5) + ), ['abcd'], 'ra.validation.minLength' ); - test( - composeValidators(required(), minLength(5)), + await test( + composeValidators( + required(), + asyncFailedValidator, + minLength(5) + ), + ['abcde'], + 'async' + ); + await test( + composeValidators( + required(), + minLength(5), + asyncSuccessfullValidator + ), ['abcde'], undefined ); diff --git a/packages/ra-core/src/form/validate.ts b/packages/ra-core/src/form/validate.ts index 5463a8df1db..44f2c1f7572 100644 --- a/packages/ra-core/src/form/validate.ts +++ b/packages/ra-core/src/form/validate.ts @@ -63,18 +63,46 @@ type Memoize = any>( const memoize: Memoize = (fn: any) => lodashMemoize(fn, (...args) => JSON.stringify(args)); +const isFunction = value => typeof value === 'function'; + +// Compose multiple validators into a single one for use with final-form +export const composeValidators = (...validators) => async ( + value, + values, + meta +) => { + const allValidators = (Array.isArray(validators[0]) + ? validators[0] + : validators + ).filter(isFunction); + + for (const validator of allValidators) { + const error = await validator(value, values, meta); + + if (error) { + return error; + } + } +}; + // Compose multiple validators into a single one for use with final-form -export const composeValidators = (...validators) => (value, values, meta) => { - const allValidators = Array.isArray(validators[0]) +export const composeSyncValidators = (...validators) => ( + value, + values, + meta +) => { + const allValidators = (Array.isArray(validators[0]) ? validators[0] - : validators; + : validators + ).filter(isFunction); - return allValidators.reduce( - (error, validator) => - error || - (typeof validator === 'function' && validator(value, values, meta)), - undefined - ); + for (const validator of allValidators) { + const error = validator(value, values, meta); + + if (error) { + return error; + } + } }; /** diff --git a/packages/ra-ui-materialui/src/input/ArrayInput.tsx b/packages/ra-ui-materialui/src/input/ArrayInput.tsx index 40bab19a934..aded393e592 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; import { cloneElement, Children, FC, ReactElement } from 'react'; import PropTypes from 'prop-types'; -import { isRequired, FieldTitle, composeValidators, InputProps } from 'ra-core'; +import { + isRequired, + FieldTitle, + composeSyncValidators, + InputProps, +} from 'ra-core'; import { useFieldArray } from 'react-final-form-arrays'; import { InputLabel, FormControl } from '@material-ui/core'; @@ -62,7 +67,7 @@ const ArrayInput: FC = ({ ...rest }) => { const sanitizedValidate = Array.isArray(validate) - ? composeValidators(validate) + ? composeSyncValidators(validate) : validate; const fieldProps = useFieldArray(source, {