Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add better support for async validators #5623

Merged
merged 3 commits into from
Dec 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/CreateEdit.md
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,38 @@ export const UserCreate = (props) => (

**Tip**: The props you pass to `<SimpleForm>` and `<TabbedForm>` are passed to the [<Form>](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) => (
<Create {...props}>
<SimpleForm validate={validateUserCreation}>
<TextInput label="First Name" source="firstName" />
<TextInput label="Email" source="email" />
<TextInput label="Age" source="age" />
</SimpleForm>
</Create>
);
```

### Per Input Validation: Built-in Field Validators

Alternatively, you can specify a `validate` prop directly in `<Input>` 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:
Expand Down Expand Up @@ -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) => (
<Create {...props}>
<SimpleForm validate={validateUserCreation}>
...
<TextInput label="Email" source="email" validate={emailValidators} />
...
</SimpleForm>
</Create>
);
```

**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`:
Expand Down
4 changes: 3 additions & 1 deletion docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,9 @@ import { ArrayInput, SimpleFormIterator, DateInput, TextInput, FormDataConsumer
</ArrayInput>
```

`<ArrayInput>` also accepts the [common input props](./Inputs.md#common-input-props) (except `format` and `parse`).
`<ArrayInput>` 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).

### `<AutocompleteArrayInput>`

Expand Down
7 changes: 6 additions & 1 deletion examples/simple/src/users/UserCreate.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const UserEditToolbar = ({
</Toolbar>
);

const isValidName = async value =>
new Promise(resolve =>
setTimeout(resolve(value === 'Admin' ? "Can't be Admin" : undefined))
);

const UserCreate = ({ permissions, ...props }) => (
<Create {...props} aside={<Aside />}>
<TabbedForm
Expand All @@ -48,7 +53,7 @@ const UserCreate = ({ permissions, ...props }) => (
source="name"
defaultValue="Slim Shady"
autoFocus
validate={required()}
validate={[required(), isValidName]}
/>
</FormTab>
{permissions === 'admin' && (
Expand Down
94 changes: 74 additions & 20 deletions packages/ra-core/src/form/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error | undefined>(
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
);
Expand Down
46 changes: 37 additions & 9 deletions packages/ra-core/src/form/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,46 @@ type Memoize = <T extends (...args: any[]) => 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;
}
}
};

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/ra-ui-materialui/src/input/ArrayInput.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -62,7 +67,7 @@ const ArrayInput: FC<ArrayInputProps> = ({
...rest
}) => {
const sanitizedValidate = Array.isArray(validate)
? composeValidators(validate)
? composeSyncValidators(validate)
: validate;

const fieldProps = useFieldArray(source, {
Expand Down