diff --git a/README.md b/README.md index 2bebf0d1..cba09d80 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,40 @@ const App = () => { export default App; ``` +### [Nope](https://github.com/bvego/nope-validator) + +A small, simple, and fast JS validator + +[![npm](https://img.shields.io/bundlephobia/minzip/nope-validator?style=for-the-badge)](https://bundlephobia.com/result?p=nope-validator) + +```typescript jsx +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { nopeResolver } from '@hookform/resolvers/nope'; +import Nope from 'nope-validator'; + +const schema = Nope.object().shape({ + name: Nope.string().required(), + age: Nope.number().required(), +}); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: nopeResolver(schema), + }); + + return ( +
console.log(d))}> + + + +
+ ); +}; + +export default App; +``` + ## Backers Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)]. diff --git a/config/node-13-exports.js b/config/node-13-exports.js index 397ec25f..9c80b09c 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -9,6 +9,7 @@ const subRepositories = [ 'superstruct', 'class-validator', 'io-ts', + 'nope', ]; const copySrc = () => { diff --git a/nope/package.json b/nope/package.json new file mode 100644 index 00000000..ded13c24 --- /dev/null +++ b/nope/package.json @@ -0,0 +1,18 @@ +{ + "name": "nope", + "amdName": "hookformResolversNope", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: nope", + "main": "dist/nope.js", + "module": "dist/nope.module.js", + "umd:main": "dist/nope.umd.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0", + "@hookform/resolvers": "^2.0.0", + "nope-validator": "^0.12.0" + } +} diff --git a/nope/src/__tests__/Form.tsx b/nope/src/__tests__/Form.tsx new file mode 100644 index 00000000..f08f8428 --- /dev/null +++ b/nope/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 Nope from 'nope-validator'; +import { nopeResolver } from '..'; + +const schema = Nope.object().shape({ + username: Nope.string().required(), + password: Nope.string().required(), +}); + +interface FormData { + unusedProperty: string; + username: string; + password: string; +} + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + formState: { errors }, + handleSubmit, + } = useForm({ + resolver: nopeResolver(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.getAllByText(/This field is required/i)).toHaveLength(2); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/nope/src/__tests__/__fixtures__/data.ts b/nope/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..c47fd60f --- /dev/null +++ b/nope/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,70 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import Nope from 'nope-validator'; + +export const schema = Nope.object().shape({ + username: Nope.string().regex(/^\w+$/).min(2).max(30).required(), + password: Nope.string() + .regex(new RegExp('.*[A-Z].*'), 'One uppercase character') + .regex(new RegExp('.*[a-z].*'), 'One lowercase character') + .regex(new RegExp('.*\\d.*'), 'One number') + .regex( + new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), + 'One special character', + ) + .min(8, 'Must be at least 8 characters in length') + .required('New Password is required'), + repeatPassword: Nope.string() + .oneOf([Nope.ref('password')], "Passwords don't match") + .required(), + accessToken: Nope.string(), + birthYear: Nope.number().min(1900).max(2013), + email: Nope.string().email(), + tags: Nope.array().of(Nope.string()).required(), + enabled: Nope.boolean(), + like: Nope.object().shape({ + id: Nope.number().required(), + name: Nope.string().atLeast(4).required(), + }), +}); + +export const validData = { + username: 'Doe', + password: 'Password123_', + repeatPassword: 'Password123_', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + like: { + id: 1, + name: 'name', + }, +}; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: { id: 'z' }, + tags: [1, 2, 3], +}; + +export const fields: Record = { + username: { + ref: { name: 'username' }, + name: 'username', + }, + password: { + ref: { name: 'password' }, + name: 'password', + }, + email: { + ref: { name: 'email' }, + name: 'email', + }, + birthday: { + ref: { name: 'birthday' }, + name: 'birthday', + }, +}; diff --git a/nope/src/__tests__/__snapshots__/nope.ts.snap b/nope/src/__tests__/__snapshots__/nope.ts.snap new file mode 100644 index 00000000..e5daad6b --- /dev/null +++ b/nope/src/__tests__/__snapshots__/nope.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`nopeResolver should return a single error from nopeResolver when validation fails 1`] = ` +Object { + "errors": Object { + "birthYear": Object { + "message": "The field is not a valid number", + "ref": undefined, + }, + "like": Object { + "id": Object { + "message": "The field is not a valid number", + "ref": undefined, + }, + "name": Object { + "message": "This field is required", + "ref": undefined, + }, + }, + "password": Object { + "message": "One uppercase character", + "ref": Object { + "name": "password", + }, + }, + "repeatPassword": Object { + "message": "This field is required", + "ref": undefined, + }, + "tags": Object { + "message": "One or more elements are of invalid type", + "ref": undefined, + }, + "username": Object { + "message": "This field is required", + "ref": Object { + "name": "username", + }, + }, + }, + "values": Object {}, +} +`; diff --git a/nope/src/__tests__/nope.ts b/nope/src/__tests__/nope.ts new file mode 100644 index 00000000..be8c81b5 --- /dev/null +++ b/nope/src/__tests__/nope.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */ +import { nopeResolver } from '..'; +import { schema, validData, fields, invalidData } from './__fixtures__/data'; + +describe('nopeResolver', () => { + it('should return values from nopeResolver when validation pass', async () => { + const schemaSpy = jest.spyOn(schema, 'validate'); + + const result = await nopeResolver(schema)(validData, undefined, { + fields, + }); + + expect(schemaSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from nopeResolver when validation fails', async () => { + const result = await nopeResolver(schema)(invalidData, undefined, { + fields, + }); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/nope/src/index.ts b/nope/src/index.ts new file mode 100644 index 00000000..70d2e237 --- /dev/null +++ b/nope/src/index.ts @@ -0,0 +1,2 @@ +export * from './nope'; +export * from './types'; diff --git a/nope/src/nope.ts b/nope/src/nope.ts new file mode 100644 index 00000000..dc279b68 --- /dev/null +++ b/nope/src/nope.ts @@ -0,0 +1,40 @@ +import type { FieldErrors } from 'react-hook-form'; +import { toNestError } from '@hookform/resolvers'; +import type { ShapeErrors } from 'nope-validator/lib/cjs/types'; +import type { Resolver } from './types'; + +const parseErrors = ( + errors: ShapeErrors, + parsedErrors: FieldErrors = {}, + path = '', +) => { + return Object.keys(errors).reduce((acc, key) => { + const _path = path ? `${path}.${key}` : key; + const error = errors[key]; + + if (typeof error === 'string') { + acc[_path] = { + message: error, + }; + } else { + parseErrors(error, acc, _path); + } + + return acc; + }, parsedErrors); +}; + +export const nopeResolver: Resolver = ( + schema, + schemaOptions = { + abortEarly: false, + }, +) => (values, context, options) => { + const result = schema.validate(values, context, schemaOptions) as + | ShapeErrors + | undefined; + + return result + ? { values: {}, errors: toNestError(parseErrors(result), options.fields) } + : { values, errors: {} }; +}; diff --git a/nope/src/types.ts b/nope/src/types.ts new file mode 100644 index 00000000..b1a283fe --- /dev/null +++ b/nope/src/types.ts @@ -0,0 +1,19 @@ +import type { + FieldValues, + ResolverOptions, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import type NopeObject from 'nope-validator/lib/cjs/NopeObject'; + +type ValidateOptions = Parameters[2]; +type Context = Parameters[1]; + +export type Resolver = ( + schema: T, + schemaOptions?: ValidateOptions, +) => ( + values: UnpackNestedValue, + context: TContext | undefined, + options: ResolverOptions, +) => ResolverResult; diff --git a/package.json b/package.json index d0778761..87ff0324 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@hookform/resolvers", "amdName": "hookformResolvers", "version": "1.3.1", - "description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator and etc.", + "description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts and Nope.", "main": "dist/resolvers.js", "module": "dist/resolvers.module.js", "umd:main": "dist/resolvers.umd.js", @@ -57,6 +57,12 @@ "import": "./io-ts/dist/io-ts.mjs", "require": "./io-ts/dist/io-ts.js" }, + "./nope": { + "browser": "./nope/dist/nope.module.js", + "umd": "./nope/dist/nope.umd.js", + "import": "./nope/dist/nope.mjs", + "require": "./nope/dist/nope.js" + }, "./package.json": "./package.json", "./": "./" }, @@ -82,7 +88,10 @@ "class-validator/dist", "io-ts/package.json", "io-ts/src", - "io-ts/dist" + "io-ts/dist", + "nope/package.json", + "nope/src", + "nope/dist" ], "publishConfig": { "access": "public" @@ -98,6 +107,7 @@ "build:io-ts": "microbundle --cwd io-ts --globals '@hookform/resolvers=hookformResolvers'", "build:vest": "microbundle --cwd vest --globals '@hookform/resolvers=hookformResolvers'", "build:class-validator": "microbundle --cwd class-validator --globals '@hookform/resolvers=hookformResolvers'", + "build:nope": "microbundle --cwd nope --globals '@hookform/resolvers=hookformResolvers'", "postbuild": "node ./config/node-13-exports.js", "lint": "eslint . --ext .ts,.js --ignore-path .gitignore", "lint:types": "tsc", @@ -118,7 +128,8 @@ "zod", "vest", "class-validator", - "io-ts" + "io-ts", + "nope" ], "repository": { "type": "git", @@ -154,6 +165,7 @@ "microbundle": "^0.13.0", "monocle-ts": "^2.3.9", "newtype-ts": "^0.3.4", + "nope-validator": "^0.12.2", "npm-run-all": "^4.1.5", "prettier": "^2.2.1", "react": "^17.0.1", diff --git a/yarn.lock b/yarn.lock index 7d9c93f5..a5299af8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4961,6 +4961,11 @@ node-releases@^1.1.67: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== +nope-validator@^0.12.2: + version "0.12.2" + resolved "https://registry.yarnpkg.com/nope-validator/-/nope-validator-0.12.2.tgz#7860c9d1ebed5d5d1d6819d19ae25779206663de" + integrity sha512-anjqqIqNKxRlo3mBfm1V4V9ShEkj+65BHEvq9yIESjaeWwAJELQl354uQUG2swjk/k6Gifc9L+RQ0iek6X34/w== + normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"