Skip to content

Commit

Permalink
feat(class-validator): add criteriaMode: all support (#151)
Browse files Browse the repository at this point in the history
* fix(class-validator): nested errors

* feat(class-validator): add `criteriaMode: all` support

* perf(class-validator): reduce classValidator resolver bundle size

* docs: update class-validator example
  • Loading branch information
jorisre authored Apr 10, 2021
1 parent defa00b commit 5f806f8
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 59 deletions.
29 changes: 12 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,23 +275,18 @@ const App = () => {
} = useForm<User>({ resolver });

return (
<div style={{ display: 'grid', placeContent: 'center', height: '90vh' }}>
<form
onSubmit={handleSubmit((data) => console.log(data))}
style={{ display: 'flex', flexDirection: 'column', rowGap: 4 }}
>
<input type="text" {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}

<input type="text" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}

<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}

<input type="submit" value="Submit" />
</form>
</div>
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input type="text" {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}

<input type="text" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}

<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}

<input type="submit" value="Submit" />
</form>
);
};

Expand Down
4 changes: 3 additions & 1 deletion class-validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"license": "MIT",
"peerDependencies": {
"react-hook-form": ">=6.6.0",
"@hookform/resolvers": ">=2.0.0"
"@hookform/resolvers": ">=2.0.0",
"class-transformer": "^0.4.0",
"class-validator": "^0.12.0"
}
}
140 changes: 135 additions & 5 deletions class-validator/src/__tests__/__snapshots__/class-validator.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,70 +6,200 @@ Object {
"birthYear": Object {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
},
"email": Object {
"message": "email must be an email",
"ref": Object {
"name": "email",
},
"type": "isEmail",
},
"like": Object {
"0": Object {
"like": Array [
Object {
"name": Object {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
},
},
],
"password": Object {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": Object {
"name": "password",
},
"type": "matches",
},
"username": Object {
"message": "username must be longer than or equal to 3 characters",
"ref": Object {
"name": "username",
},
"type": "isLength",
},
},
"values": Object {},
}
`;

exports[`classValidatorResolver should return a single error from classValidatorResolver with \`mode: sync\` when validation fails 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
},
"email": Object {
"message": "email must be an email",
"ref": Object {
"name": "email",
},
"type": "isEmail",
},
"like": Array [
Object {
"name": Object {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
},
},
],
"password": Object {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": Object {
"name": "password",
},
"type": "matches",
},
"username": Object {
"message": "username must be longer than or equal to 3 characters",
"ref": Object {
"name": "username",
},
"type": "isLength",
},
},
"values": Object {},
}
`;

exports[`classValidatorResolver should return a single error from classValidatorResolver with \`mode: sync\` when validation fails 1`] = `
exports[`classValidatorResolver should return all the errors from classValidatorResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
"types": Object {
"max": "birthYear must not be greater than 2013",
"min": "birthYear must not be less than 1900",
},
},
"email": Object {
"message": "email must be an email",
"ref": Object {
"name": "email",
},
"type": "isEmail",
"types": Object {
"isEmail": "email must be an email",
},
},
"like": Object {
"0": Object {
"like": Array [
Object {
"name": Object {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
"types": Object {
"isLength": "name must be longer than or equal to 4 characters",
},
},
},
],
"password": Object {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": Object {
"name": "password",
},
"type": "matches",
"types": Object {
"matches": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
},
},
"username": Object {
"message": "username must be longer than or equal to 3 characters",
"ref": Object {
"name": "username",
},
"type": "isLength",
"types": Object {
"isLength": "username must be longer than or equal to 3 characters",
"matches": "username must match /^\\\\w+$/ regular expression",
},
},
},
"values": Object {},
}
`;

exports[`classValidatorResolver should return all the errors from classValidatorResolver when validation fails with \`validateAllFieldCriteria\` set to true and \`mode: sync\` 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
"types": Object {
"max": "birthYear must not be greater than 2013",
"min": "birthYear must not be less than 1900",
},
},
"email": Object {
"message": "email must be an email",
"ref": Object {
"name": "email",
},
"type": "isEmail",
"types": Object {
"isEmail": "email must be an email",
},
},
"like": Array [
Object {
"name": Object {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
"types": Object {
"isLength": "name must be longer than or equal to 4 characters",
},
},
},
],
"password": Object {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": Object {
"name": "password",
},
"type": "matches",
"types": Object {
"matches": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
},
},
"username": Object {
"message": "username must be longer than or equal to 3 characters",
"ref": Object {
"name": "username",
},
"type": "isLength",
"types": Object {
"isLength": "username must be longer than or equal to 3 characters",
"matches": "username must match /^\\\\w+$/ regular expression",
},
},
},
"values": Object {},
Expand Down
24 changes: 24 additions & 0 deletions class-validator/src/__tests__/class-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,28 @@ describe('classValidatorResolver', () => {
expect(validateSpy).not.toHaveBeenCalled();
expect(result).toMatchSnapshot();
});

it('should return all the errors from classValidatorResolver when validation fails with `validateAllFieldCriteria` set to true', async () => {
const result = await classValidatorResolver(Schema)(
invalidData,
undefined,
{
fields,
criteriaMode: 'all',
},
);

expect(result).toMatchSnapshot();
});

it('should return all the errors from classValidatorResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => {
const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(invalidData, undefined, {
fields,
criteriaMode: 'all',
});

expect(result).toMatchSnapshot();
});
});
78 changes: 42 additions & 36 deletions class-validator/src/class-validator.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@
import type { Resolver } from './types';
import { FieldErrors } from 'react-hook-form';
import { toNestError } from '@hookform/resolvers';
import { plainToClass } from 'class-transformer';
import { validate, validateSync, ValidationError } from 'class-validator';
import { toNestError } from '@hookform/resolvers';
import type { Resolver } from './types';

const fromEntries = (entries: [any, any][]) => {
return entries.reduce((prev, [k, v]) => ({ ...prev, [k]: v }), {});
};
const parseErrors = (
errors: ValidationError[],
validateAllFieldCriteria: boolean,
parsedErrors: FieldErrors = {},
path = '',
) => {
return errors.reduce((acc, error) => {
const _path = path ? `${path}.${error.property}` : error.property;

const getErrorMessages = (rawError: ValidationError): any => {
const res =
rawError.children && rawError.children.length > 0
? fromEntries(
rawError.children.map((child) => {
return [child.property, getErrorMessages(child)];
}),
)
: {
message: Object.entries(rawError.constraints ?? {})?.[0]?.[1],
};

return res;
};
if (error.constraints) {
const key = Object.keys(error.constraints)[0];
acc[_path] = {
type: key,
message: error.constraints[key],
};

if (validateAllFieldCriteria && acc[_path]) {
Object.assign(acc[_path], { types: error.constraints });
}
}

if (error.children && error.children.length) {
parseErrors(error.children, validateAllFieldCriteria, acc, _path);
}

const parseErrors = (rawErrors: ValidationError[]) => {
const errors = fromEntries(
rawErrors.map((rawError) => [
rawError.property,
getErrorMessages(rawError),
]),
);
return rawErrors.length > 0 ? errors : {};
return acc;
}, parsedErrors);
};

export const classValidatorResolver: Resolver = (
Expand All @@ -38,13 +39,18 @@ export const classValidatorResolver: Resolver = (
resolverOptions = {},
) => async (values, _, options) => {
const user = plainToClass(schema, values);
const rawErrors =
resolverOptions.mode === 'sync'
? validateSync(user, schemaOptions)
: await validate(user, schemaOptions);
if (rawErrors.length === 0) {
return { values, errors: {} };
}
const errors = toNestError(parseErrors(rawErrors), options.fields);
return { values: {}, errors };

const rawErrors = await (resolverOptions.mode === 'sync'
? validateSync
: validate)(user, schemaOptions);

return rawErrors.length
? {
values: {},
errors: toNestError(
parseErrors(rawErrors, options.criteriaMode === 'all'),
options.fields,
),
}
: { values, errors: {} };
};

0 comments on commit 5f806f8

Please sign in to comment.