From b7dd0e6038b5f9e7b2bf004489cb3a09923f3b6b Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 9 Jan 2021 23:48:58 +0100 Subject: [PATCH] Fix: vest validate all criteria + better TypeSript support + add tests * test: add components test to ensure TypeScript is working as expected Related to #97 * chore: improve zod resolvers types * chore(eslint): remove warnings * test: add severals tests & improve typings * chore: setup compressed-size * test: update describe --- .eslintrc.js | 6 + .github/workflows/compressedSize.yml | 13 + jest.config.js | 3 +- joi/src/__tests__/Form.tsx | 57 ++++ joi/src/__tests__/__snapshots__/joi.ts.snap | 72 ++++- joi/src/__tests__/joi.ts | 64 +++- joi/src/index.ts | 1 + joi/src/joi.ts | 20 +- joi/src/types.ts | 15 + package.json | 5 + superstruct/src/__tests__/Form.tsx | 58 ++++ .../__snapshots__/superstruct.ts.snap | 97 ++++-- superstruct/src/__tests__/superstruct.ts | 85 ++++-- superstruct/src/index.ts | 1 + superstruct/src/superstruct.ts | 26 +- superstruct/src/types.ts | 17 ++ tsconfig.json | 3 +- vest/src/__tests__/Form.tsx | 60 ++++ vest/src/__tests__/vest.ts | 4 +- vest/src/types.ts | 23 ++ vest/src/vest.ts | 19 +- yarn.lock | 155 +++++++++- yup/src/__tests__/Form.tsx | 50 ++++ yup/src/__tests__/__snapshots__/yup.ts.snap | 91 ++---- yup/src/__tests__/yup.ts | 275 ++++-------------- yup/src/index.ts | 1 + yup/src/types.ts | 17 ++ yup/src/yup.ts | 21 +- zod/src/__tests__/Form.tsx | 50 ++++ zod/src/__tests__/__snapshots__/zod.ts.snap | 160 +++------- zod/src/__tests__/zod.ts | 130 +++------ zod/src/index.ts | 1 + zod/src/types.ts | 16 + zod/src/zod.ts | 19 +- 34 files changed, 1007 insertions(+), 628 deletions(-) create mode 100644 .github/workflows/compressedSize.yml create mode 100644 joi/src/__tests__/Form.tsx create mode 100644 joi/src/types.ts create mode 100644 superstruct/src/__tests__/Form.tsx create mode 100644 superstruct/src/types.ts create mode 100644 vest/src/__tests__/Form.tsx create mode 100644 vest/src/types.ts create mode 100644 yup/src/__tests__/Form.tsx create mode 100644 yup/src/types.ts create mode 100644 zod/src/__tests__/Form.tsx create mode 100644 zod/src/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index 977b63e3..2f3b63a3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,12 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-object-literal-type-assertion': 'off', 'no-console': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + }, + ], }, overrides: [ { diff --git a/.github/workflows/compressedSize.yml b/.github/workflows/compressedSize.yml new file mode 100644 index 00000000..a2624b7a --- /dev/null +++ b/.github/workflows/compressedSize.yml @@ -0,0 +1,13 @@ +name: Compressed Size + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: preactjs/compressed-size-action@v2 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/jest.config.js b/jest.config.js index 742f6fda..0445b64d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,11 @@ module.exports = { preset: 'ts-jest', - testEnvironment: 'node', + testEnvironment: 'jsdom', restoreMocks: true, testMatch: ['**/__tests__/**/*.+(js|jsx|ts|tsx)'], transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], moduleNameMapper: { '^@hookform/resolvers$': '/src', }, + setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], }; diff --git a/joi/src/__tests__/Form.tsx b/joi/src/__tests__/Form.tsx new file mode 100644 index 00000000..7d6d0f41 --- /dev/null +++ b/joi/src/__tests__/Form.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import * as Joi from 'joi'; +import { joiResolver } from '..'; + +const schema = Joi.object({ + username: Joi.string().required(), + password: Joi.string().required(), +}); + +interface FormData { + username: string; + password: string; +} + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, errors, handleSubmit } = useForm({ + resolver: joiResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Joi and TypeScript's integration", async () => { + const handleSubmit = jest.fn(); + render(); + + expect(screen.queryAllByRole(/alert/i)).toHaveLength(0); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + expect( + screen.getByText(/"username" is not allowed to be empty/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/"password" is not allowed to be empty/i), + ).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/joi/src/__tests__/__snapshots__/joi.ts.snap b/joi/src/__tests__/__snapshots__/joi.ts.snap index 57f226fc..1202d999 100644 --- a/joi/src/__tests__/__snapshots__/joi.ts.snap +++ b/joi/src/__tests__/__snapshots__/joi.ts.snap @@ -1,11 +1,81 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`joiResolver should return errors 1`] = ` +exports[`joiResolver should return a single error from joiResolver when validation fails 1`] = ` Object { "errors": Object { + "birthYear": Object { + "message": "\\"birthYear\\" must be a number", + "type": "number.base", + }, + "email": Object { + "message": "\\"email\\" is not allowed to be empty", + "type": "string.empty", + }, + "enabled": Object { + "message": "\\"enabled\\" is required", + "type": "any.required", + }, + "password": Object { + "message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/", + "type": "string.pattern.base", + }, + "tags": Object { + "message": "\\"tags\\" is required", + "type": "any.required", + }, + "username": Object { + "message": "\\"username\\" is required", + "type": "any.required", + }, + }, + "values": Object {}, +} +`; + +exports[`joiResolver should return all the errors from joiResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +Object { + "errors": Object { + "birthYear": Object { + "message": "\\"birthYear\\" must be a number", + "type": "number.base", + "types": Object { + "number.base": "\\"birthYear\\" must be a number", + }, + }, + "email": Object { + "message": "\\"email\\" is not allowed to be empty", + "type": "string.empty", + "types": Object { + "string.empty": "\\"email\\" is not allowed to be empty", + }, + }, + "enabled": Object { + "message": "\\"enabled\\" is required", + "type": "any.required", + "types": Object { + "any.required": "\\"enabled\\" is required", + }, + }, + "password": Object { + "message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/", + "type": "string.pattern.base", + "types": Object { + "string.pattern.base": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/", + }, + }, + "tags": Object { + "message": "\\"tags\\" is required", + "type": "any.required", + "types": Object { + "any.required": "\\"tags\\" is required", + }, + }, "username": Object { "message": "\\"username\\" is required", "type": "any.required", + "types": Object { + "any.required": "\\"username\\" is required", + }, }, }, "values": Object {}, diff --git a/joi/src/__tests__/joi.ts b/joi/src/__tests__/joi.ts index 3799baa3..38369fd8 100644 --- a/joi/src/__tests__/joi.ts +++ b/joi/src/__tests__/joi.ts @@ -3,31 +3,67 @@ import { joiResolver } from '..'; const schema = Joi.object({ username: Joi.string().alphanum().min(3).max(30).required(), - - password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')), - + password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')).required(), repeatPassword: Joi.ref('password'), - accessToken: [Joi.string(), Joi.number()], - birthYear: Joi.number().integer().min(1900).max(2013), - email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] }, }), + tags: Joi.array().items(Joi.string()).required(), + enabled: Joi.boolean().required(), }); +interface Data { + username: string; + password: string; + repeatPassword: string; + accessToken?: number | string; + birthYear?: number; + email?: string; + tags: string[]; + enabled: boolean; +} + describe('joiResolver', () => { - it('should return correct value', async () => { - const data = { username: 'abc', birthYear: 1994 }; - expect(await joiResolver(schema)(data)).toEqual({ - values: data, - errors: {}, - }); + it('should return values from joiResolver when validation pass', async () => { + const data: Data = { + username: 'Doe', + password: 'Password123', + repeatPassword: 'Password123', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + }; + + const result = await joiResolver(schema)(data); + + expect(result).toEqual({ errors: {}, values: data }); }); - it('should return errors', async () => { - expect(await joiResolver(schema)({})).toMatchSnapshot(); + it('should return a single error from joiResolver when validation fails', async () => { + const data = { + password: '___', + email: '', + birthYear: 'birthYear', + }; + + const result = await joiResolver(schema)(data); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from joiResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const data = { + password: '___', + email: '', + birthYear: 'birthYear', + }; + + const result = await joiResolver(schema)(data, undefined, true); + + expect(result).toMatchSnapshot(); }); }); diff --git a/joi/src/index.ts b/joi/src/index.ts index 259cdc6c..1e9d42ab 100644 --- a/joi/src/index.ts +++ b/joi/src/index.ts @@ -1 +1,2 @@ export * from './joi'; +export * from './types'; diff --git a/joi/src/joi.ts b/joi/src/joi.ts index 3f8f47cb..00b5a077 100644 --- a/joi/src/joi.ts +++ b/joi/src/joi.ts @@ -1,12 +1,8 @@ -import { - appendErrors, - transformToNestObject, - Resolver, - FieldValues, -} from 'react-hook-form'; +import { appendErrors, transformToNestObject } from 'react-hook-form'; import * as Joi from 'joi'; // @ts-expect-error maybe fixed after the first publish ? import { convertArrayToPathName } from '@hookform/resolvers'; +import { Resolver } from './types'; const parseErrorSchema = ( error: Joi.ValidationError, @@ -48,16 +44,12 @@ const parseErrorSchema = ( ) : []; -export const joiResolver = ( - schema: Joi.Schema, - options: Joi.AsyncValidationOptions = { +export const joiResolver: Resolver = ( + schema, + options = { abortEarly: false, }, -): Resolver => async ( - values, - _, - validateAllFieldCriteria = false, -) => { +) => async (values, _, validateAllFieldCriteria = false) => { try { return { values: await schema.validateAsync(values, { diff --git a/joi/src/types.ts b/joi/src/types.ts new file mode 100644 index 00000000..6faf8880 --- /dev/null +++ b/joi/src/types.ts @@ -0,0 +1,15 @@ +import { + FieldValues, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import type { AsyncValidationOptions, Schema } from 'joi'; + +export type Resolver = ( + schema: T, + options?: AsyncValidationOptions, +) => ( + values: UnpackNestedValue, + context?: TContext, + validateAllFieldCriteria?: boolean, +) => Promise>; diff --git a/package.json b/package.json index 0a26c139..9f5b1d11 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,11 @@ }, "homepage": "https://react-hook-form.com", "devDependencies": { + "@testing-library/jest-dom": "^5.11.8", + "@testing-library/react": "^11.2.2", + "@testing-library/user-event": "^12.6.0", "@types/jest": "^26.0.19", + "@types/react": "^17.0.0", "@typescript-eslint/eslint-plugin": "^4.11.1", "@typescript-eslint/parser": "^4.11.1", "check-export-map": "^1.0.1", @@ -124,6 +128,7 @@ "npm-run-all": "^4.1.5", "prettier": "^2.2.1", "react": "^17.0.1", + "react-dom": "^17.0.1", "react-hook-form": "^6.14.0", "semantic-release": "^17.3.1", "superstruct": "^0.13.1", diff --git a/superstruct/src/__tests__/Form.tsx b/superstruct/src/__tests__/Form.tsx new file mode 100644 index 00000000..c1170fa2 --- /dev/null +++ b/superstruct/src/__tests__/Form.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import { object, string, Infer, size } from 'superstruct'; +import { superstructResolver } from '..'; + +const schema = object({ + username: size(string(), 2), + password: size(string(), 6), +}); + +type FormData = Infer; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, errors, handleSubmit } = useForm({ + resolver: superstructResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Superstruct and TypeScript's integration", async () => { + const handleSubmit = jest.fn(); + render(); + + expect(screen.queryAllByRole(/alert/i)).toHaveLength(0); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + expect( + screen.getByText( + /Expected a string with a length of `2` but received one with a length of `0`/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /Expected a string with a length of `6` but received one with a length of `0`/i, + ), + ).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/superstruct/src/__tests__/__snapshots__/superstruct.ts.snap b/superstruct/src/__tests__/__snapshots__/superstruct.ts.snap index 23111f8a..6562231f 100644 --- a/superstruct/src/__tests__/__snapshots__/superstruct.ts.snap +++ b/superstruct/src/__tests__/__snapshots__/superstruct.ts.snap @@ -1,31 +1,92 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`superstructResolver should return errors 1`] = ` +exports[`superstructResolver should return a single error from superstructResolver when validation fails 1`] = ` Object { "errors": Object { - "author": Object { - "id": Object { - "message": "Expected a number, but received: \\"test\\"", - "type": "number", - }, + "birthYear": Object { + "message": "Expected a number, but received: \\"birthYear\\"", + "type": "number", + }, + "email": Object { + "message": "Expected a string matching \`/^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$/\` but received \\"\\"", + "type": "string", + }, + "enabled": Object { + "message": "Expected a value of type \`boolean\`, but received: \`undefined\`", + "type": "boolean", + }, + "password": Object { + "message": "Expected a string matching \`/^[a-zA-Z0-9]{3,30}/\` but received \\"___\\"", + "type": "string", + }, + "repeatPassword": Object { + "message": "Expected a value of type \`Password\`, but received: \`undefined\`", + "type": "Password", }, - "id": Object { - "message": "Expected a number, but received: \\"2\\"", + "tags": Object { + "message": "Expected an array value, but received: undefined", + "type": "array", + }, + "username": Object { + "message": "Expected a string, but received: undefined", + "type": "string", + }, + }, + "values": Object {}, +} +`; + +exports[`superstructResolver should return all the errors from superstructResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +Object { + "errors": Object { + "birthYear": Object { + "message": "Expected a number, but received: \\"birthYear\\"", "type": "number", + "types": Object { + "number": "Expected a number, but received: \\"birthYear\\"", + }, }, - "tags": Array [ - Object { - "message": "Expected a string, but received: 2", - "type": "string", + "email": Object { + "message": "Expected a string matching \`/^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$/\` but received \\"\\"", + "type": "string", + "types": Object { + "string": "Expected a string matching \`/^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$/\` but received \\"\\"", }, - Object { - "message": "Expected a string, but received: 3", - "type": "string", + }, + "enabled": Object { + "message": "Expected a value of type \`boolean\`, but received: \`undefined\`", + "type": "boolean", + "types": Object { + "boolean": "Expected a value of type \`boolean\`, but received: \`undefined\`", }, - ], - "title": Object { - "message": "Expected a string, but received: 2", + }, + "password": Object { + "message": "Expected a string matching \`/^[a-zA-Z0-9]{3,30}/\` but received \\"___\\"", "type": "string", + "types": Object { + "string": "Expected a string matching \`/^[a-zA-Z0-9]{3,30}/\` but received \\"___\\"", + }, + }, + "repeatPassword": Object { + "message": "Expected a value of type \`Password\`, but received: \`undefined\`", + "type": "Password", + "types": Object { + "Password": "Expected a value of type \`Password\`, but received: \`undefined\`", + }, + }, + "tags": Object { + "message": "Expected an array value, but received: undefined", + "type": "array", + "types": Object { + "array": "Expected an array value, but received: undefined", + }, + }, + "username": Object { + "message": "Expected a string, but received: undefined", + "type": "string", + "types": Object { + "string": "Expected a string, but received: undefined", + }, }, }, "values": Object {}, diff --git a/superstruct/src/__tests__/superstruct.ts b/superstruct/src/__tests__/superstruct.ts index dc5e0346..39c69f46 100644 --- a/superstruct/src/__tests__/superstruct.ts +++ b/superstruct/src/__tests__/superstruct.ts @@ -1,45 +1,72 @@ -import { object, number, string, boolean, array, optional } from 'superstruct'; +import { + object, + number, + string, + optional, + pattern, + size, + union, + min, + max, + Infer, + define, + array, + boolean, +} from 'superstruct'; import { superstructResolver } from '..'; -const Article = object({ - id: number(), - title: string(), - isPublished: optional(boolean()), +const Password = define('Password', (value, ctx) => + value === ctx.branch[0].password); + +const schema = object({ + username: size(pattern(string(), /^\w+$/), 3, 30), + password: pattern(string(), /^[a-zA-Z0-9]{3,30}/), + repeatPassword: Password, + accessToken: optional(union([string(), number()])), + birthYear: optional(max(min(number(), 1900), 2013)), + email: optional(pattern(string(), /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/)), tags: array(string()), - author: object({ - id: number(), - }), + enabled: boolean(), }); describe('superstructResolver', () => { - it('should return correct value', async () => { + it('should return values from superstructResolver when validation pass', async () => { + const data: Infer = { + username: 'Doe', + password: 'Password123', + repeatPassword: 'Password123', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + }; + + const result = await superstructResolver(schema)(data); + + expect(result).toEqual({ errors: {}, values: data }); + }); + + it('should return a single error from superstructResolver when validation fails', async () => { const data = { - id: 2, - title: 'test', - tags: ['news', 'features'], - author: { - id: 1, - }, + password: '___', + email: '', + birthYear: 'birthYear', }; - expect(await superstructResolver(Article)(data)).toEqual({ - values: data, - errors: {}, - }); + const result = await superstructResolver(schema)(data); + + expect(result).toMatchSnapshot(); }); - it('should return errors', async () => { + it('should return all the errors from superstructResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { const data = { - id: '2', - title: 2, - tags: [2, 3], - author: { - id: 'test', - }, + password: '___', + email: '', + birthYear: 'birthYear', }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - For testing purpose `id`'s type is wrong - expect(await superstructResolver(Article)(data)).toMatchSnapshot(); + const result = await superstructResolver(schema)(data, undefined, true); + + expect(result).toMatchSnapshot(); }); }); diff --git a/superstruct/src/index.ts b/superstruct/src/index.ts index 2acaa550..d45b047a 100644 --- a/superstruct/src/index.ts +++ b/superstruct/src/index.ts @@ -1 +1,2 @@ export * from './superstruct'; +export * from './types'; diff --git a/superstruct/src/superstruct.ts b/superstruct/src/superstruct.ts index 428ed1f1..ce4aed2e 100644 --- a/superstruct/src/superstruct.ts +++ b/superstruct/src/superstruct.ts @@ -1,13 +1,8 @@ -import { - appendErrors, - transformToNestObject, - Resolver, - ResolverSuccess, - ResolverError, -} from 'react-hook-form'; -import { StructError, validate, Struct, Infer } from 'superstruct'; +import { appendErrors, transformToNestObject } from 'react-hook-form'; +import { StructError, validate } from 'superstruct'; // @ts-expect-error maybe fixed after the first publish ? import { convertArrayToPathName } from '@hookform/resolvers'; +import { Resolver } from './types'; const parseErrorSchema = ( error: StructError, @@ -45,12 +40,11 @@ const parseErrorSchema = ( }; }, {}); -type Options = Parameters[2]; - -export const superstructResolver = >( - schema: T, - options?: Options, -): Resolver> => (values, _, validateAllFieldCriteria = false) => { +export const superstructResolver: Resolver = (schema, options) => async ( + values, + _context, + validateAllFieldCriteria = false, +) => { const [errors, result] = validate(values, schema, options); if (errors != null) { @@ -59,11 +53,11 @@ export const superstructResolver = >( errors: transformToNestObject( parseErrorSchema(errors, validateAllFieldCriteria), ), - } as ResolverError>; + }; } return { values: result, errors: {}, - } as ResolverSuccess>; + }; }; diff --git a/superstruct/src/types.ts b/superstruct/src/types.ts new file mode 100644 index 00000000..8cf3036e --- /dev/null +++ b/superstruct/src/types.ts @@ -0,0 +1,17 @@ +import { + FieldValues, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import { validate, Struct } from 'superstruct'; + +type Options = Parameters[2]; + +export type Resolver = >( + schema: T, + options?: Options, +) => ( + values: UnpackNestedValue, + context?: TContext, + validateAllFieldCriteria?: boolean, +) => Promise>; diff --git a/tsconfig.json b/tsconfig.json index 511027a2..3da87507 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strictNullChecks": true, - "isolatedModules": true + "isolatedModules": true, + "jsx": "react" } } diff --git a/vest/src/__tests__/Form.tsx b/vest/src/__tests__/Form.tsx new file mode 100644 index 00000000..7e3a05c9 --- /dev/null +++ b/vest/src/__tests__/Form.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import vest from 'vest'; +import { vestResolver } from '..'; + +interface FormData { + username: string; + password: string; +} + +const validationSuite = vest.create('form', (data: FormData) => { + vest.test('username', 'Username is required', () => { + vest.enforce(data.username).isNotEmpty(); + }); + + vest.test('password', 'Password must contain a symbol', () => { + vest.enforce(data.password).matches(/[^A-Za-z0-9]/); + }); +}); + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, errors, handleSubmit } = useForm({ + resolver: vestResolver(validationSuite), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Vest and TypeScript's integration", async () => { + const handleSubmit = jest.fn(); + render(); + + expect(screen.queryAllByRole(/alert/i)).toHaveLength(0); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + expect(screen.getByText(/Username is required/i)).toBeInTheDocument(); + expect( + screen.getByText(/Password must contain a symbol/i), + ).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/vest/src/__tests__/vest.ts b/vest/src/__tests__/vest.ts index d77dcedd..30d9ba39 100644 --- a/vest/src/__tests__/vest.ts +++ b/vest/src/__tests__/vest.ts @@ -31,7 +31,7 @@ const validationSuite = vest.create('form', (data: any = {}) => { }); }); -describe('vest', () => { +describe('vestResolver', () => { it('should return values from vestResolver when validation pass', async () => { const data = { username: 'asdda', @@ -85,7 +85,7 @@ describe('vest', () => { }, }; - expect(await vestResolver(validationSuite, {}, true)(data, {})).toEqual({ + expect(await vestResolver(validationSuite, {})(data, {}, true)).toEqual({ values: {}, errors: { username: { diff --git a/vest/src/types.ts b/vest/src/types.ts new file mode 100644 index 00000000..43d6005c --- /dev/null +++ b/vest/src/types.ts @@ -0,0 +1,23 @@ +import { + FieldValues, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import * as Vest from 'vest'; + +export type ICreateResult = ReturnType; + +export type Resolver = ( + schema: ICreateResult, + options?: any, +) => ( + values: UnpackNestedValue, + context?: TContext, + validateAllFieldCriteria?: boolean, +) => Promise>; + +export type VestErrors = Record; + +export type Promisify = ( + fn: T, +) => (args: K) => Promise; diff --git a/vest/src/vest.ts b/vest/src/vest.ts index 15594c3f..9d9fccbe 100644 --- a/vest/src/vest.ts +++ b/vest/src/vest.ts @@ -1,13 +1,6 @@ -import { FieldValues, Resolver, transformToNestObject } from 'react-hook-form'; +import { transformToNestObject } from 'react-hook-form'; import * as Vest from 'vest'; - -type VestErrors = Record; - -type ICreateResult = ReturnType; - -type Promisify = ( - fn: T, -) => (args: K) => Promise; +import type { Promisify, VestErrors, Resolver } from './types'; const promisify: Promisify = (validatorFn) => (...args) => new Promise((resolve) => validatorFn(...args).done(resolve as Vest.DoneCB)); @@ -37,11 +30,11 @@ const parseErrorSchema = ( }, {}); }; -export const vestResolver = ( - schema: ICreateResult, - _: any = {}, +export const vestResolver: Resolver = (schema, _ = {}) => async ( + values, + _context, validateAllFieldCriteria = false, -): Resolver => async (values) => { +) => { const validateSchema = promisify(schema); const result = await validateSchema(values); const errors = result.getErrors(); diff --git a/yarn.lock b/yarn.lock index af369e95..6a296857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -862,7 +862,15 @@ "@babel/plugin-transform-react-jsx-development" "^7.12.7" "@babel/plugin-transform-react-pure-annotations" "^7.12.1" -"@babel/runtime@^7.10.5", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4": +"@babel/runtime-corejs3@^7.10.2": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" + integrity sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ== + dependencies: + core-js-pure "^3.0.0" + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== @@ -1425,11 +1433,59 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@testing-library/dom@^7.28.1": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.29.1.tgz#a08ebeb26b2ea859b1621ff9642d114c1f04fe3a" + integrity sha512-6BU7vAjKuMspCy9QQEtbWgmkuXi/yOSZo3ANdvZmNQW8N/WQGjO9cvlcA5EFJaPtp2hL1RAaPGpCXxumijUxCg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.4" + lz-string "^1.4.4" + pretty-format "^26.6.2" + +"@testing-library/jest-dom@^5.11.8": + version "5.11.8" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.8.tgz#433a84d6f9a089485101b9e112ef03e5c30bcbfc" + integrity sha512-ScyKrWQM5xNcr79PkSewnA79CLaoxVskE+f7knTOhDD9ftZSA1Jw8mj+pneqhEu3x37ncNfW84NUr7lqK+mXjA== + dependencies: + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^4.2.2" + chalk "^3.0.0" + css "^3.0.0" + css.escape "^1.5.1" + lodash "^4.17.15" + redent "^3.0.0" + +"@testing-library/react@^11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.2.tgz#099c6c195140ff069211143cb31c0f8337bdb7b7" + integrity sha512-jaxm0hwUjv+hzC+UFEywic7buDC9JQ1q3cDsrWVSDAPmLotfA6E6kUHlYm/zOeGCac6g48DR36tFHxl7Zb+N5A== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^7.28.1" + +"@testing-library/user-event@^12.6.0": + version "12.6.0" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-12.6.0.tgz#2d0229e399eb5a0c6c112e848611432356cac886" + integrity sha512-FNEH/HLmOk5GO70I52tKjs7WvGYckeE/SrnLX/ip7z2IGbffyd5zOUM1tZ10vsTphqm+VbDFI0oaXu0wcfQsAQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/aria-query@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" + integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.12" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d" @@ -1499,7 +1555,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@26.x", "@types/jest@^26.0.19": +"@types/jest@*", "@types/jest@26.x", "@types/jest@^26.0.19": version "26.0.19" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.19.tgz#e6fa1e3def5842ec85045bd5210e9bb8289de790" integrity sha512-jqHoirTG61fee6v6rwbnEuKhpSKih0tuhqeFbCmMmErhtu3BYlOZaXWjffgOstMM4S/3iQD31lI5bGLTrs97yQ== @@ -1547,11 +1603,24 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" integrity sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA== +"@types/prop-types@*": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" + integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -1569,6 +1638,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== +"@types/testing-library__jest-dom@^5.9.1": + version "5.9.5" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0" + integrity sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ== + dependencies: + "@types/jest" "*" + "@types/yargs-parser@*": version "20.2.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" @@ -1862,6 +1938,14 @@ argv-formatter@~1.0.0: resolved "https://registry.yarnpkg.com/argv-formatter/-/argv-formatter-1.0.0.tgz#a0ca0cbc29a5b73e836eebe1cbf6c5e0e4eb82f9" integrity sha1-oMoMvCmltz6Dbuvhy/bF4OTrgvk= +aria-query@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" + integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA== + dependencies: + "@babel/runtime" "^7.10.2" + "@babel/runtime-corejs3" "^7.10.2" + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -2380,6 +2464,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.2, chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -2783,6 +2875,11 @@ core-js-compat@^3.8.0: browserslist "^4.15.0" semver "7.0.0" +core-js-pure@^3.0.0: + version "3.8.2" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.8.2.tgz#286f885c0dac1cdcd6d78397392abc25ddeca225" + integrity sha512-v6zfIQqL/pzTVAbZvYUozsxNfxcFb6Ks3ZfEbuneJl3FW9Jb8F6vLWB6f+qTmAu72msUdyb84V8d/yBFf7FNnw== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -2915,6 +3012,20 @@ css-what@^3.2.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= + +css@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + dependencies: + inherits "^2.0.4" + source-map "^0.6.1" + source-map-resolve "^0.6.0" + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -3012,6 +3123,11 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8" + integrity sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -3221,6 +3337,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" + integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -6097,6 +6218,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" @@ -7936,6 +8062,15 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-dom@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" + integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.1" + react-hook-form@^6.14.0: version "6.14.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.14.0.tgz#d11f81f2d9d2b457806cb425a79954a1eeba1871" @@ -8499,6 +8634,14 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" +scheduler@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" + integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + semantic-release@^17.3.1: version "17.3.1" resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.1.tgz#8904ef1ca8e704394de0e204b284f6c252284da4" @@ -8776,6 +8919,14 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.6, source-map-support@~0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" diff --git a/yup/src/__tests__/Form.tsx b/yup/src/__tests__/Form.tsx new file mode 100644 index 00000000..6242da9f --- /dev/null +++ b/yup/src/__tests__/Form.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import * as Yup from 'yup'; +import { yupResolver } from '..'; + +const schema = Yup.object({ + username: Yup.string().required(), + password: Yup.string().required(), +}); + +type FormData = Yup.InferType & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, errors, handleSubmit } = useForm({ + resolver: yupResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Yup and TypeScript's integration", async () => { + const handleSubmit = jest.fn(); + render(); + + expect(screen.queryAllByRole(/alert/i)).toHaveLength(0); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + expect(screen.getByText(/username is a required field/i)).toBeInTheDocument(); + expect(screen.getByText(/password is a required field/i)).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/yup/src/__tests__/__snapshots__/yup.ts.snap b/yup/src/__tests__/__snapshots__/yup.ts.snap index bb944b19..1b8e0b73 100644 --- a/yup/src/__tests__/__snapshots__/yup.ts.snap +++ b/yup/src/__tests__/__snapshots__/yup.ts.snap @@ -1,14 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateWithSchema should return undefined when no error reported 1`] = ` +exports[`yupResolver should return a single error from yupResolver when validation fails 1`] = ` Object { "errors": Object { - "age": Object { - "message": "age is a required field", - "type": "required", + "birthYear": Object { + "message": "birthYear must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"birthYear\\"\`).", + "type": "typeError", }, - "name": Object { - "message": "name is a required field", + "password": Object { + "message": "password must match the following: \\"/^[a-zA-Z0-9]{3,30}/\\"", + "type": "matches", + }, + "username": Object { + "message": "username is a required field", "type": "required", }, }, @@ -16,48 +20,28 @@ Object { } `; -exports[`yupResolver errors should get errors with validate all criteria fields 1`] = ` +exports[`yupResolver should return all the errors from yupResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` Object { "errors": Object { - "age": Object { - "message": "age must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"test\\"\`).", + "birthYear": Object { + "message": "birthYear must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"birthYear\\"\`).", "type": "typeError", "types": Object { - "typeError": "age must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"test\\"\`).", + "typeError": "birthYear must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"birthYear\\"\`).", }, }, - "createdOn": Object { - "message": "createdOn must be a \`date\` type, but the final value was: \`Invalid Date\`.", - "type": "typeError", + "password": Object { + "message": "password must match the following: \\"/^[a-zA-Z0-9]{3,30}/\\"", + "type": "matches", "types": Object { - "typeError": "createdOn must be a \`date\` type, but the final value was: \`Invalid Date\`.", + "matches": "password must match the following: \\"/^[a-zA-Z0-9]{3,30}/\\"", }, }, - "foo": Array [ - Object { - "loose": Object { - "message": "foo[0].loose must be a \`boolean\` type, but the final value was: \`null\`. - If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`", - "type": "typeError", - "types": Object { - "typeError": "foo[0].loose must be a \`boolean\` type, but the final value was: \`null\`. - If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`", - }, - }, - }, - ], - "password": Object { - "message": "password is a required field", + "username": Object { + "message": "username is a required field", "type": "required", "types": Object { - "matches": Array [ - "Lowercase", - "Uppercase", - "Number", - "Special", - ], - "min": "password must be at least 8 characters", - "required": "password is a required field", + "required": "username is a required field", }, }, }, @@ -65,36 +49,19 @@ Object { } `; -exports[`yupResolver errors should get errors without validate all criteria fields 1`] = ` +exports[`yupResolver should return an error from yupResolver when validation fails and pass down the yup context 1`] = ` Object { "errors": Object { - "age": Object { - "message": "age must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"test\\"\`).", - "type": "typeError", - }, - "createdOn": Object { - "message": "createdOn must be a \`date\` type, but the final value was: \`Invalid Date\`.", - "type": "typeError", - }, - "foo": Array [ - Object { - "loose": Object { - "message": "foo[0].loose must be a \`boolean\` type, but the final value was: \`null\`. - If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`", - "type": "typeError", - }, - }, - ], - "password": Object { - "message": "password is a required field", - "type": "required", + "name": Object { + "message": "name must be at least 6 characters", + "type": "min", }, }, "values": Object {}, } `; -exports[`yupResolver errors should return an error result if inner yup validation error has no path 1`] = ` +exports[`yupResolver should return an error result if inner yup validation error has no path 1`] = ` Object { "errors": Object { "required": Object { @@ -106,12 +73,12 @@ Object { } `; -exports[`yupResolver should pass down the yup context 1`] = ` +exports[`yupResolver should return correct error message with using yup.test 1`] = ` Object { "errors": Object { "name": Object { - "message": "name must be at least 6 characters", - "type": "min", + "message": "Email or name are required", + "type": "name", }, }, "values": Object {}, diff --git a/yup/src/__tests__/yup.ts b/yup/src/__tests__/yup.ts index bce38468..9e9b61d4 100644 --- a/yup/src/__tests__/yup.ts +++ b/yup/src/__tests__/yup.ts @@ -2,90 +2,63 @@ import * as yup from 'yup'; import { yupResolver } from '..'; -const errors = { - name: 'ValidationError', - value: { createdOn: '2019-03-27T04:05:51.503Z' }, - path: undefined, - type: undefined, - errors: ['name is a required field', 'age is a required field'], - inner: [ - { - name: 'ValidationError', - value: undefined, - path: 'name', - type: 'required', - errors: [], - inner: [], - message: 'name is a required field', - params: [], - }, - { - name: 'ValidationError', - value: undefined, - path: 'name', - type: 'min', - errors: [], - inner: [], - message: 'name is a min field', - params: [], - }, - { - name: 'ValidationError', - value: undefined, - path: 'age', - type: 'required', - errors: [], - inner: [], - message: 'age is a required field', - params: [], - }, - ], -}; - const schema = yup.object({ - name: yup.string().required(), - age: yup.number().required().positive().integer(), - email: yup.string().email(), + username: yup.string().matches(/^\w+$/).min(3).max(30).required(), password: yup .string() - .required() - .min(8) - .matches(RegExp('(.*[a-z].*)'), 'Lowercase') - .matches(RegExp('(.*[A-Z].*)'), 'Uppercase') - .matches(RegExp('(.*\\d.*)'), 'Number') - .matches(RegExp('[!@#$%^&*(),.?":{}|<>]'), 'Special'), - website: yup.string().url(), - createdOn: yup.date().default(function () { - return new Date(); - }), - foo: yup - .array() - .required() - .of( - yup.object({ - loose: yup.boolean(), - }), - ), + .matches(/^[a-zA-Z0-9]{3,30}/) + .required(), + repeatPassword: yup.ref('password'), + accessToken: yup.string(), + birthYear: yup.number().min(1900).max(2013), + email: yup.string().email(), + tags: yup.array(yup.string()), + enabled: yup.boolean(), }); describe('yupResolver', () => { - it('should get values', async () => { + it('should return values from yupResolver when validation pass', async () => { + const data: yup.InferType = { + username: 'Doe', + password: 'Password123', + repeatPassword: 'Password123', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + }; + + const result = await yupResolver(schema)(data); + + expect(result).toEqual({ errors: {}, values: data }); + }); + + it('should return a single error from yupResolver when validation fails', async () => { const data = { - name: 'jimmy', - age: 24, - email: 'jimmy@mail.com', - password: '[}tehk6Uor', - website: 'https://react-hook-form.com/', - createdOn: new Date('2014-09-23T19:25:25Z'), - foo: [{ loose: true }], + password: '___', + email: '', + birthYear: 'birthYear', }; - expect(await yupResolver(schema)(data)).toEqual({ - errors: {}, - values: data, - }); + + const result = await yupResolver(schema)(data); + + expect(result).toMatchSnapshot(); }); - it('should pass down the yup context', async () => { + it('should return all the errors from yupResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const data = { + password: '___', + email: '', + birthYear: 'birthYear', + }; + + const result = await yupResolver(schema)(data, undefined, true); + + expect(result).toMatchSnapshot(); + }); + + it('should return an error from yupResolver when validation fails and pass down the yup context', async () => { const data = { name: 'eric' }; const context = { min: true }; const schemaWithContext = yup.object({ @@ -99,7 +72,7 @@ describe('yupResolver', () => { const schemaSpyValidate = jest.spyOn(schemaWithContext, 'validate'); - const output = await yupResolver(schemaWithContext)(data, context); + const result = await yupResolver(schemaWithContext)(data, context); expect(schemaSpyValidate).toHaveBeenCalledTimes(1); expect(schemaSpyValidate).toHaveBeenCalledWith( data, @@ -108,147 +81,20 @@ describe('yupResolver', () => { context, }), ); - expect(output).toMatchSnapshot(); + expect(result).toMatchSnapshot(); }); - describe('errors', () => { - it('should get errors with validate all criteria fields', async () => { - const data = { - name: 2, - age: 'test', - password: '', - createdOn: null, - foo: [{ loose: null }], - }; - - const output = await yupResolver(schema)(data, {}, true); - expect(output).toMatchSnapshot(); - expect(output.errors['foo']?.[0]?.['loose']).toBeDefined(); - expect(output.errors['foo']?.[0]?.['loose']?.types) - .toMatchInlineSnapshot(` - Object { - "typeError": "foo[0].loose must be a \`boolean\` type, but the final value was: \`null\`. - If \\"null\\" is intended as an empty value be sure to mark the schema as \`.nullable()\`", - } - `); - expect(output.errors.age?.types).toMatchInlineSnapshot(` - Object { - "typeError": "age must be a \`number\` type, but the final value was: \`NaN\` (cast from the value \`\\"test\\"\`).", - } - `); - expect(output.errors.createdOn?.types).toMatchInlineSnapshot(` - Object { - "typeError": "createdOn must be a \`date\` type, but the final value was: \`Invalid Date\`.", - } - `); - expect(output.errors.password?.types).toMatchInlineSnapshot(` - Object { - "matches": Array [ - "Lowercase", - "Uppercase", - "Number", - "Special", - ], - "min": "password must be at least 8 characters", - "required": "password is a required field", - } - `); + it('should return an error result if inner yup validation error has no path', async () => { + const yupSchema = yup.object({ + name: yup.string().required(), }); - it('should get errors without validate all criteria fields', async () => { - const data = { - name: 2, - age: 'test', - createdOn: null, - foo: [{ loose: null }], - }; - - const output = await yupResolver(schema)(data); - expect(output).toMatchSnapshot(); - expect(output.errors.age?.types).toBeUndefined(); - expect(output.errors.createdOn?.types).toBeUndefined(); - expect(output.errors.password?.types).toBeUndefined(); - }); - - it('should get error if yup errors has no inner errors', async () => { - const data = { - name: 2, - age: 'test', - createdOn: null, - foo: [{ loose: null }], - }; - const output = await yupResolver(schema, { - abortEarly: true, - })(data, undefined, true); - - expect(output.errors).toMatchInlineSnapshot(` - Object { - "createdOn": Object { - "message": "createdOn must be a \`date\` type, but the final value was: \`Invalid Date\`.", - "type": "typeError", - }, - } - `); + jest.spyOn(yupSchema, 'validate').mockRejectedValueOnce({ + inner: [{ message: 'error1', type: 'required' }], }); - it('should return an error result if inner yup validation error has no path', async () => { - const data = { name: '' }; - const schemaWithContext = yup.object().shape({ - name: yup.string().required(), - }); - - jest.spyOn(schemaWithContext, 'validate').mockRejectedValueOnce({ - inner: [{ path: '', message: 'error1', type: 'required' }], - }); - - const output = await yupResolver(schemaWithContext)(data); - expect(output).toMatchSnapshot(); - }); - }); -}); - -describe('validateWithSchema', () => { - it('should return undefined when no error reported', async () => { - const schema = yup.object(); - jest.spyOn(schema, 'validate').mockRejectedValueOnce(errors); - - expect(await yupResolver(schema)({})).toMatchSnapshot(); - }); - - it('should return empty object when validate pass', async () => { - const schema = yup.object(); - - expect(await yupResolver(schema)({})).toMatchInlineSnapshot(` - Object { - "errors": Object {}, - "values": Object {}, - } - `); - }); - - it('should return an error based on the user context', async () => { - const data = { name: 'eric' }; - const schemaWithContext = yup.object().shape({ - name: yup - .string() - .required() - .when('$min', (min: boolean, schema: yup.StringSchema) => { - return min ? schema.min(6) : schema; - }), - }); - - expect(await yupResolver(schemaWithContext)(data, { min: true })) - .toMatchInlineSnapshot(` - Object { - "errors": Object { - "name": Object { - "message": "name must be at least 6 characters", - "type": "min", - }, - }, - "values": Object {}, - } - `); + const result = await yupResolver(yupSchema)({ name: '' }); + expect(result).toMatchSnapshot(); }); it('should show a warning log if yup context is used instead only on dev environment', async () => { @@ -262,7 +108,7 @@ describe('validateWithSchema', () => { process.env.NODE_ENV = 'test'; }); - it('should not show warning log if yup context is used instead only on production environment', async () => { + it('should not show a warning log if yup context is used instead only on production environment', async () => { jest.spyOn(console, 'warn').mockImplementation(jest.fn); process.env.NODE_ENV = 'production'; @@ -272,7 +118,7 @@ describe('validateWithSchema', () => { }); it('should return correct error message with using yup.test', async () => { - const output = await yupResolver( + const result = await yupResolver( yup .object({ name: yup.string(), @@ -285,9 +131,6 @@ describe('validateWithSchema', () => { ), )({ name: '', email: '' }); - expect(output).toEqual({ - values: {}, - errors: { name: { message: 'Email or name are required', type: 'name' } }, - }); + expect(result).toMatchSnapshot(); }); }); diff --git a/yup/src/index.ts b/yup/src/index.ts index 13c89e6e..ea7705c8 100644 --- a/yup/src/index.ts +++ b/yup/src/index.ts @@ -1 +1,2 @@ export * from './yup'; +export * from './types'; diff --git a/yup/src/types.ts b/yup/src/types.ts new file mode 100644 index 00000000..bd7a8ae6 --- /dev/null +++ b/yup/src/types.ts @@ -0,0 +1,17 @@ +import { + FieldValues, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import * as Yup from 'yup'; + +type Options = Parameters[1]; + +export type Resolver = ( + schema: T, + options?: Options, +) => ( + values: UnpackNestedValue, + context?: TContext, + validateAllFieldCriteria?: boolean, +) => Promise>; diff --git a/yup/src/yup.ts b/yup/src/yup.ts index b1be8fc0..72169711 100644 --- a/yup/src/yup.ts +++ b/yup/src/yup.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { FieldValues, Resolver, transformToNestObject } from 'react-hook-form'; +import { transformToNestObject } from 'react-hook-form'; import Yup from 'yup'; +import { Resolver } from './types'; /** * From 0.32.0, Yup add TypeScript support and `path` typing is optional that's why we have `@ts-expect-error` @@ -53,20 +54,12 @@ const parseErrorSchema = ( }; }; -type ValidateOptions = Parameters< - T['validate'] ->[1]; - -export const yupResolver = ( - schema: Yup.AnyObjectSchema, - options: ValidateOptions = { +export const yupResolver: Resolver = ( + schema, + options = { abortEarly: false, }, -): Resolver => async ( - values, - context, - validateAllFieldCriteria = false, -) => { +) => async (values, context, validateAllFieldCriteria = false) => { try { if (options.context && process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console @@ -74,6 +67,7 @@ export const yupResolver = ( "You should not used the yup options context. Please, use the 'useForm' context object instead", ); } + return { values: await schema.validate(values, { ...options, @@ -83,6 +77,7 @@ export const yupResolver = ( }; } catch (e) { const parsedErrors = parseErrorSchema(e, validateAllFieldCriteria); + return { values: {}, errors: transformToNestObject(parsedErrors), diff --git a/zod/src/__tests__/Form.tsx b/zod/src/__tests__/Form.tsx new file mode 100644 index 00000000..78009ebc --- /dev/null +++ b/zod/src/__tests__/Form.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import * as Zod from 'zod'; +import { zodResolver } from '..'; + +const schema = Zod.object({ + username: Zod.string().nonempty({ message: 'username field is required' }), + password: Zod.string().nonempty({ message: 'password field is required' }), +}); + +type FormData = Zod.infer & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, errors, handleSubmit } = useForm({ + resolver: zodResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Zod and TypeScript's integration", async () => { + const handleSubmit = jest.fn(); + render(); + + expect(screen.queryAllByRole(/alert/i)).toHaveLength(0); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + expect(screen.getByText(/username field is required/i)).toBeInTheDocument(); + expect(screen.getByText(/password field is required/i)).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/zod/src/__tests__/__snapshots__/zod.ts.snap b/zod/src/__tests__/__snapshots__/zod.ts.snap index ce8958fb..1bb824df 100644 --- a/zod/src/__tests__/__snapshots__/zod.ts.snap +++ b/zod/src/__tests__/__snapshots__/zod.ts.snap @@ -1,97 +1,37 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`zodResolver should get errors with zod error map 1`] = ` +exports[`zodResolver should return a single error from zodResolver when validation fails 1`] = ` Object { "errors": Object { - "author": Object { - "id": Object { - "message": "Expected number, received string", - "type": "invalid_type", - }, - }, - "count": Object { - "message": "Value should be greater than 0", - "type": "too_small", - }, - "date": Object { - "message": "Expected date, received string", - "type": "invalid_type", - }, - "id": Object { - "message": "Expected number, received string", - "type": "invalid_type", - }, - "password": Object { - "message": "Should be at least 8 characters", - "type": "too_small", - }, - "tags": Array [ - Object { - "message": "This ain't a string!", - "type": "invalid_type", - }, - Object { - "message": "This ain't a string!", - "type": "invalid_type", - }, - ], - "title": Object { - "message": "This ain't a string!", - "type": "invalid_type", - }, - "url": Object { - "message": "This ain't a string!", - "type": "invalid_type", - }, - }, - "values": Object {}, -} -`; - -exports[`zodResolver should get errors without validate all criteria fields 1`] = ` -Object { - "errors": Object { - "": Object { - "message": "Unrecognized key(s) in object: 'unknownProperty'", - "type": "unrecognized_keys", - }, - "author": Object { - "id": Object { - "message": "Expected number, received string", - "type": "invalid_type", - }, + "birthYear": Object { + "message": "Invalid input", + "type": "invalid_union", }, "confirm": Object { "message": "Passwords don't match", "type": "custom_error", }, - "date": Object { - "message": "Expected date, received string", - "type": "invalid_type", + "email": Object { + "message": "Invalid email", + "type": "invalid_string", }, - "id": Object { - "message": "Expected number, received string", + "enabled": Object { + "message": "Required", "type": "invalid_type", }, "password": Object { - "message": "Should be at least 8 characters", - "type": "too_small", + "message": "Invalid", + "type": "invalid_string", }, - "tags": Array [ - Object { - "message": "Expected string, received number", - "type": "invalid_type", - }, - Object { - "message": "Expected string, received boolean", - "type": "invalid_type", - }, - ], - "title": Object { + "repeatPassword": Object { "message": "Required", "type": "invalid_type", }, - "url": Object { + "tags": Object { + "message": "Required", + "type": "invalid_type", + }, + "username": Object { "message": "Required", "type": "invalid_type", }, @@ -100,71 +40,59 @@ Object { } `; -exports[`zodResolver should get errors without validate all criteria fields 2`] = ` +exports[`zodResolver should return all the errors from zodResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` Object { "errors": Object { - "author": Object { - "id": Object { - "message": "Expected number, received string", - "type": "invalid_type", - "types": Object { - "invalid_type": "Expected number, received string", - }, + "birthYear": Object { + "message": "Invalid input", + "type": "invalid_union", + "types": Object { + "invalid_union": "Invalid input", }, }, - "count": Object { - "message": "Value should be greater than 0", - "type": "too_small", + "confirm": Object { + "message": "Passwords don't match", + "type": "custom_error", "types": Object { - "too_small": "Value should be greater than 0", + "custom_error": "Passwords don't match", }, }, - "date": Object { - "message": "Expected date, received string", - "type": "invalid_type", + "email": Object { + "message": "Invalid email", + "type": "invalid_string", "types": Object { - "invalid_type": "Expected date, received string", + "invalid_string": "Invalid email", }, }, - "id": Object { - "message": "Expected number, received string", + "enabled": Object { + "message": "Required", "type": "invalid_type", "types": Object { - "invalid_type": "Expected number, received string", + "invalid_type": "Required", }, }, "password": Object { - "message": "Should be at least 8 characters", - "type": "too_small", + "message": "Invalid", + "type": "invalid_string", "types": Object { "invalid_string": "Invalid", - "too_small": "Should be at least 8 characters", }, }, - "tags": Array [ - Object { - "message": "Expected string, received number", - "type": "invalid_type", - "types": Object { - "invalid_type": "Expected string, received number", - }, - }, - Object { - "message": "Expected string, received boolean", - "type": "invalid_type", - "types": Object { - "invalid_type": "Expected string, received boolean", - }, + "repeatPassword": Object { + "message": "Required", + "type": "invalid_type", + "types": Object { + "invalid_type": "Required", }, - ], - "title": Object { + }, + "tags": Object { "message": "Required", "type": "invalid_type", "types": Object { "invalid_type": "Required", }, }, - "url": Object { + "username": Object { "message": "Required", "type": "invalid_type", "types": Object { diff --git a/zod/src/__tests__/zod.ts b/zod/src/__tests__/zod.ts index aa89380d..b83c44d1 100644 --- a/zod/src/__tests__/zod.ts +++ b/zod/src/__tests__/zod.ts @@ -3,120 +3,58 @@ import { zodResolver } from '..'; const schema = z .object({ - id: z.number(), - title: z.string(), - isPublished: z.boolean().optional(), + username: z.string().regex(/^\w+$/).min(3).max(30), + password: z.string().regex(/^[a-zA-Z0-9]{3,30}/), + repeatPassword: z.string(), + accessToken: z.union([z.string(), z.number()]).optional(), + birthYear: z.number().min(1900).max(2013).optional(), + email: z.string().email().optional(), tags: z.array(z.string()), - author: z.object({ - id: z.number(), - }), - count: z.number().positive().int(), - date: z.date(), - url: z.string().url(), - password: z - .string() - .min(8) - .regex(RegExp('(.*[a-z].*)')) - .regex(RegExp('(.*[A-Z].*)')) - .regex(RegExp('(.*\\d.*)')) - .regex(RegExp('[!@#$%^&*(),.?":{}|<>]')), - confirm: z.string(), + enabled: z.boolean(), }) - .refine((data) => data.password === data.confirm, { + .refine((data) => data.password === data.repeatPassword, { message: "Passwords don't match", path: ['confirm'], // set path of error }); describe('zodResolver', () => { - it('should get values', async () => { - const data = { - id: 2, - title: 'test', - tags: ['news', 'features'], - author: { - id: 1, - }, - count: 4, - date: new Date(), - url: 'https://github.com/react-hook-form/resolvers', - password: '[}tehk6Uor', - confirm: '[}tehk6Uor', + it('should return values from zodResolver when validation pass', async () => { + const data: z.infer = { + username: 'Doe', + password: 'Password123', + repeatPassword: 'Password123', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, }; - expect(await zodResolver(schema)(data)).toEqual({ - values: data, - errors: {}, - }); - }); + const result = await zodResolver(schema)(data); - it('should get errors without validate all criteria fields', async () => { - const data: any = { - id: '2', - tags: [2, true], - author: { - id: '1', - }, - count: 1, - date: 'date', - password: 'R', - confirm: 'A', - unknownProperty: '', - }; - - expect(await zodResolver(schema)(data)).toMatchSnapshot(); + expect(result).toEqual({ errors: {}, values: data }); }); - it('should get errors without validate all criteria fields', async () => { - const data: any = { - id: '2', - tags: [2, true], - author: { - id: '1', - }, - count: -5, - date: 'date', - password: 'R', - confirm: 'R', + it('should return a single error from zodResolver when validation fails', async () => { + const data = { + password: '___', + email: '', + birthYear: 'birthYear', }; - expect(await zodResolver(schema)(data, undefined, true)).toMatchSnapshot(); + const result = await zodResolver(schema)(data); + + expect(result).toMatchSnapshot(); }); - it('should get errors with zod error map', async () => { - const data: any = { - id: '2', - tags: [2, true], - author: { - id: '1', - }, - count: -5, - date: 'date', - password: 'R', - confirm: 'R', + it('should return all the errors from zodResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const data = { + password: '___', + email: '', + birthYear: 'birthYear', }; - const errorMap: z.ZodErrorMap = (error, ctx) => { - if (error.message) { - return { message: error.message }; - } - - switch (error.code) { - case z.ZodErrorCode.invalid_type: - if (error.expected === 'string') { - return { message: `This ain't a string!` }; - } - break; - case z.ZodErrorCode.custom_error: - const params = error.params || {}; - if (params.myField) { - return { message: `Bad input: ${params.myField}` }; - } - break; - } - - return { message: ctx.defaultError }; - }; + const result = await zodResolver(schema)(data, undefined, true); - expect(await zodResolver(schema, { errorMap })(data)).toMatchSnapshot(); + expect(result).toMatchSnapshot(); }); }); diff --git a/zod/src/index.ts b/zod/src/index.ts index 6748f26c..a60a00eb 100644 --- a/zod/src/index.ts +++ b/zod/src/index.ts @@ -1 +1,2 @@ export * from './zod'; +export * from './types'; diff --git a/zod/src/types.ts b/zod/src/types.ts new file mode 100644 index 00000000..9dc4e9e7 --- /dev/null +++ b/zod/src/types.ts @@ -0,0 +1,16 @@ +import { + FieldValues, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import * as z from 'zod'; +import type { ParseParams } from 'zod/lib/src/parser'; + +export type Resolver = >( + schema: T, + options?: ParseParams, +) => ( + values: UnpackNestedValue, + context?: TContext, + validateAllFieldCriteria?: boolean, +) => Promise>; diff --git a/zod/src/zod.ts b/zod/src/zod.ts index 16107acd..7a7d528a 100644 --- a/zod/src/zod.ts +++ b/zod/src/zod.ts @@ -1,14 +1,8 @@ -import { - appendErrors, - Resolver, - ResolverError, - ResolverSuccess, - transformToNestObject, -} from 'react-hook-form'; +import { appendErrors, transformToNestObject } from 'react-hook-form'; import * as z from 'zod'; -import { ParseParams } from 'zod/lib/src/parser'; // @ts-expect-error maybe fixed after the first publish ? import { convertArrayToPathName } from '@hookform/resolvers'; +import type { Resolver } from './types'; const parseErrorSchema = ( zodError: z.ZodError, @@ -53,10 +47,7 @@ const parseErrorSchema = ( ); }; -export const zodResolver = >( - schema: T, - options?: ParseParams, -): Resolver> => async ( +export const zodResolver: Resolver = (schema, options) => async ( values, _, validateAllFieldCriteria = false, @@ -64,7 +55,7 @@ export const zodResolver = >( const result = schema.safeParse(values, options); if (result.success) { - return { values: result.data, errors: {} } as ResolverSuccess>; + return { values: result.data, errors: {} }; } return { @@ -72,5 +63,5 @@ export const zodResolver = >( errors: transformToNestObject( parseErrorSchema(result.error, validateAllFieldCriteria), ), - } as ResolverError>; + }; };