From 2678ad43fd63dc7d5644f025de9f1845fc9f6e3d Mon Sep 17 00:00:00 2001 From: Michael Stramel Date: Sat, 31 Jul 2021 08:07:05 -0500 Subject: [PATCH] Add Typanion Resolver (#189) --- README.md | 43 ++++++++- config/node-13-exports.js | 1 + package.json | 18 +++- typanion/package.json | 18 ++++ .../src/__tests__/Form-native-validation.tsx | 88 +++++++++++++++++++ typanion/src/__tests__/Form.tsx | 57 ++++++++++++ typanion/src/__tests__/__fixtures__/data.ts | 77 ++++++++++++++++ .../__tests__/__snapshots__/typanion.ts.snap | 67 ++++++++++++++ typanion/src/__tests__/typanion.ts | 31 +++++++ typanion/src/index.ts | 2 + typanion/src/typanion.ts | 37 ++++++++ typanion/src/types.ts | 20 +++++ yarn.lock | 5 ++ 13 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 typanion/package.json create mode 100644 typanion/src/__tests__/Form-native-validation.tsx create mode 100644 typanion/src/__tests__/Form.tsx create mode 100644 typanion/src/__tests__/__fixtures__/data.ts create mode 100644 typanion/src/__tests__/__snapshots__/typanion.ts.snap create mode 100644 typanion/src/__tests__/typanion.ts create mode 100644 typanion/src/index.ts create mode 100644 typanion/src/typanion.ts create mode 100644 typanion/src/types.ts diff --git a/README.md b/README.md index 27bec570..06929c77 100644 --- a/README.md +++ b/README.md @@ -377,8 +377,7 @@ import Schema, { number, string } from 'computed-types'; const schema = Schema({ username: string.min(1).error('username field is required'), - password: string.min(1).error('password field is required'), - password: number, + age: number, }); const App = () => { @@ -404,6 +403,46 @@ const App = () => { export default App; ``` +### [typanion](https://github.com/arcanis/typanion) + +Static and runtime type assertion library with no dependencies + +[![npm](https://img.shields.io/bundlephobia/minzip/typanion?style=for-the-badge)](https://bundlephobia.com/result?p=typanion) + +```tsx +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { typanionResolver } from '@hookform/resolvers/typanion'; +import * as t from 'typanion'; + +const isUser = t.isObject({ + username: t.applyCascade(t.isString(), [t.hasMinLength(1)]), + age: t.applyCascade(t.isNumber(), [t.isInteger(), t.isInInclusiveRange(1, 100)]), +}); + +const App = () => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: typanionResolver(isUser), + }); + + return ( +
console.log(d))}> + + {errors.name?.message &&

{errors.name?.message}

} + + {errors.age?.message &&

{errors.age?.message}

} + +
+ ); +}; + +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 6b6ae31b..447f4e1c 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -11,6 +11,7 @@ const subRepositories = [ 'io-ts', 'nope', 'computed-types', + 'typanion' ]; const copySrc = () => { diff --git a/package.json b/package.json index 5d9e2bd6..fdffb166 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, io-ts, Nope and computed-types", + "description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts, Nope, computed-types and Typanion", "main": "dist/resolvers.js", "module": "dist/resolvers.module.js", "umd:main": "dist/resolvers.umd.js", @@ -69,6 +69,12 @@ "import": "./computed-types/dist/computed-types.mjs", "require": "./computed-types/dist/computed-types.js" }, + "./typanion": { + "browser": "./typanion/dist/typanion.module.js", + "umd": "./typanion/dist/typanion.umd.js", + "import": "./typanion/dist/typanion.mjs", + "require": "./typanion/dist/typanion.js" + }, "./package.json": "./package.json", "./": "./" }, @@ -100,7 +106,10 @@ "nope/dist", "computed-types/package.json", "computed-types/src", - "computed-types/dist" + "computed-types/dist", + "typanion/package.json", + "typanion/src", + "typanion/dist" ], "publishConfig": { "access": "public" @@ -118,6 +127,7 @@ "build:class-validator": "microbundle --cwd class-validator --globals '@hookform/resolvers=hookformResolvers'", "build:nope": "microbundle --cwd nope --globals '@hookform/resolvers=hookformResolvers'", "build:computed-types": "microbundle --cwd computed-types --globals '@hookform/resolvers=hookformResolvers'", + "build:typanion": "microbundle --cwd typanion --globals '@hookform/resolvers=hookformResolvers'", "postbuild": "node ./config/node-13-exports.js", "lint": "eslint . --ext .ts,.js --ignore-path .gitignore", "lint:types": "tsc", @@ -140,7 +150,8 @@ "class-validator", "io-ts", "nope", - "computed-types" + "computed-types", + "typanion" ], "repository": { "type": "git", @@ -185,6 +196,7 @@ "reflect-metadata": "^0.1.13", "superstruct": "^0.15.2", "ts-jest": "^27.0.1", + "typanion": "^3.3.2", "typescript": "^4.3.2", "vest": "^3.2.3", "yup": "^0.32.9", diff --git a/typanion/package.json b/typanion/package.json new file mode 100644 index 00000000..90a4ba0c --- /dev/null +++ b/typanion/package.json @@ -0,0 +1,18 @@ +{ + "name": "typanion", + "amdName": "hookformResolversTypanion", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: typanion", + "main": "dist/typanion.js", + "module": "dist/typanion.module.js", + "umd:main": "dist/typanion.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", + "typanion": "^3.3.2" + } +} diff --git a/typanion/src/__tests__/Form-native-validation.tsx b/typanion/src/__tests__/Form-native-validation.tsx new file mode 100644 index 00000000..9058c7bf --- /dev/null +++ b/typanion/src/__tests__/Form-native-validation.tsx @@ -0,0 +1,88 @@ +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 t from 'typanion'; +import { typanionResolver } from '..'; + +const ERROR_MESSAGE = 'Expected to have a length of at least 1 elements (got 0)'; + +const schema = t.isObject({ + username: t.applyCascade(t.isString(), [t.hasMinLength(1)]), + password: t.applyCascade(t.isString(), [t.hasMinLength(1)]), +}); + +interface FormData { + unusedProperty: string; + username: string; + password: string; +} + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: typanionResolver(schema), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with Typanion", async () => { + const handleSubmit = jest.fn(); + render(); + + // username + let usernameField = screen.getByPlaceholderText( + /username/i, + ) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(true); + expect(usernameField.validationMessage).toBe(''); + + // password + let passwordField = screen.getByPlaceholderText( + /password/i, + ) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(true); + expect(passwordField.validationMessage).toBe(''); + + await act(async () => { + user.click(screen.getByText(/submit/i)); + }); + + // username + usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(false); + expect(usernameField.validationMessage).toBe(ERROR_MESSAGE); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(false); + expect(passwordField.validationMessage).toBe(ERROR_MESSAGE); + + await act(async () => { + user.type(screen.getByPlaceholderText(/username/i), 'joe'); + user.type(screen.getByPlaceholderText(/password/i), 'password'); + }); + + // username + usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(true); + expect(usernameField.validationMessage).toBe(''); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(true); + expect(passwordField.validationMessage).toBe(''); +}); diff --git a/typanion/src/__tests__/Form.tsx b/typanion/src/__tests__/Form.tsx new file mode 100644 index 00000000..5493fbf7 --- /dev/null +++ b/typanion/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 t from 'typanion'; +import { typanionResolver } from '..'; + +const schema = t.isObject({ + username: t.applyCascade(t.isString(), [t.hasMinLength(1)]), + password: t.applyCascade(t.isString(), [t.hasMinLength(1)]), +}); + +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: typanionResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Typanion 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('Expected to have a length of at least 1 elements (got 0)')).toHaveLength(2); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/typanion/src/__tests__/__fixtures__/data.ts b/typanion/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..06ba7a30 --- /dev/null +++ b/typanion/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,77 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import * as t from 'typanion'; + + + +export const isSchema = t.isObject({ + username: t.applyCascade(t.isString(), [ + t.matchesRegExp(/^\w+$/), + t.hasMinLength(2), + t.hasMaxLength(30), + ]), + password: t.applyCascade(t.isString(), [ + t.matchesRegExp(new RegExp('.*[A-Z].*')), // one uppercase character + t.matchesRegExp(new RegExp('.*[a-z].*')), // one lowercase character + t.matchesRegExp(new RegExp('.*\\d.*')), // one number + t.matchesRegExp(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*')), // one special character + t.hasMinLength(8), // Must be at least 8 characters in length + ]), + repeatPassword: t.applyCascade(t.isString(), [ + t.matchesRegExp(new RegExp('.*[A-Z].*')), // one uppercase character + t.matchesRegExp(new RegExp('.*[a-z].*')), // one lowercase character + t.matchesRegExp(new RegExp('.*\\d.*')), // one number + t.matchesRegExp(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*')), // one special character + t.hasMinLength(8), // Must be at least 8 characters in length + ]), + accessToken: t.isString(), + birthYear: t.applyCascade(t.isNumber(), [t.isInteger(), t.isInInclusiveRange(1900, 2013)]), + email: t.applyCascade(t.isString(), [t.matchesRegExp(/^\S+@\S+$/)]), + tags: t.isArray(t.isString()), + enabled: t.isBoolean(), + like: t.isObject({ + id: t.applyCascade(t.isNumber(), [t.isInteger(), t.isPositive()]), + name: t.applyCascade(t.isString(), [t.hasMinLength(4)]) + }), +}); + +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/typanion/src/__tests__/__snapshots__/typanion.ts.snap b/typanion/src/__tests__/__snapshots__/typanion.ts.snap new file mode 100644 index 00000000..90716ea5 --- /dev/null +++ b/typanion/src/__tests__/__snapshots__/typanion.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`typanionResolver should return a single error from typanionResolver when validation fails 1`] = ` +Object { + "errors": Object { + "accessToken": Object { + "message": "Expected a string (got undefined)", + "ref": undefined, + }, + "birthYear": Object { + "message": "Expected a number (got \\"birthYear\\")", + "ref": undefined, + }, + "email": Object { + "message": "Expected to match the pattern /^\\\\S+@\\\\S+$/ (got an empty string)", + "ref": Object { + "name": "email", + }, + }, + "enabled": Object { + "message": "Expected a boolean (got undefined)", + "ref": undefined, + }, + "like": Object { + "id": Object { + "message": "Expected a number (got \\"z\\")", + "ref": undefined, + }, + "name": Object { + "message": "Expected a string (got undefined)", + "ref": undefined, + }, + }, + "password": Object { + "message": "Expected to match the pattern /.*[A-Z].*/ (got \\"___\\")", + "ref": Object { + "name": "password", + }, + }, + "repeatPassword": Object { + "message": "Expected a string (got undefined)", + "ref": undefined, + }, + "tags": Array [ + Object { + "message": "Expected a string (got 1)", + "ref": undefined, + }, + Object { + "message": "Expected a string (got 2)", + "ref": undefined, + }, + Object { + "message": "Expected a string (got 3)", + "ref": undefined, + }, + ], + "username": Object { + "message": "Expected a string (got undefined)", + "ref": Object { + "name": "username", + }, + }, + }, + "values": Object {}, +} +`; diff --git a/typanion/src/__tests__/typanion.ts b/typanion/src/__tests__/typanion.ts new file mode 100644 index 00000000..ede718cc --- /dev/null +++ b/typanion/src/__tests__/typanion.ts @@ -0,0 +1,31 @@ +import { typanionResolver } from '..'; +import { isSchema, validData, fields, invalidData } from './__fixtures__/data'; + +const tmpObj = { + validate: isSchema +} + +const shouldUseNativeValidation = false; + +describe('typanionResolver', () => { + it('should return values from typanionResolver when validation pass', async () => { + const schemaSpy = jest.spyOn(tmpObj, 'validate') + + const result = await typanionResolver(schemaSpy as any)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(schemaSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from typanionResolver when validation fails', async () => { + const result = await typanionResolver(isSchema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/typanion/src/index.ts b/typanion/src/index.ts new file mode 100644 index 00000000..492ec467 --- /dev/null +++ b/typanion/src/index.ts @@ -0,0 +1,2 @@ +export * from './typanion'; +export * from './types'; diff --git a/typanion/src/typanion.ts b/typanion/src/typanion.ts new file mode 100644 index 00000000..5ff352c5 --- /dev/null +++ b/typanion/src/typanion.ts @@ -0,0 +1,37 @@ +import type { FieldErrors } from 'react-hook-form'; +import { toNestError, validateFieldsNatively } from '@hookform/resolvers'; +import type { Resolver } from './types'; + +const parseErrors = ( + errors: string[], + parsedErrors: FieldErrors = {}, + _path = '', +) => { + return errors.reduce((acc, error) => { + const [_key, _message] = error.split(':') + const key = _key.slice(1) + const message = _message.trim() + + acc[key] = { + message, + }; + + return acc; + }, parsedErrors); +}; + +export const typanionResolver: Resolver = + (validator, validatorOptions = {}) => + (values, _, options) => { + const rawErrors: string[] = [] + const isValid = validator(values, {errors: rawErrors, ...validatorOptions}) + const parsedErrors = parseErrors(rawErrors) + + if (!isValid) { + return { values: {}, errors: toNestError(parsedErrors, options) }; + } + + options.shouldUseNativeValidation && validateFieldsNatively(parsedErrors, options); + + return { values, errors: {} }; + }; \ No newline at end of file diff --git a/typanion/src/types.ts b/typanion/src/types.ts new file mode 100644 index 00000000..584fba98 --- /dev/null +++ b/typanion/src/types.ts @@ -0,0 +1,20 @@ +import type { + FieldValues, + ResolverOptions, + ResolverResult, + UnpackNestedValue, +} from 'react-hook-form'; +import { ValidationState, AnyStrictValidator} from 'typanion' + +type ValidateOptions = Pick + +type RHFResolver = ( + values: UnpackNestedValue, + context: TContext | undefined, + options: ResolverOptions, +) => ResolverResult; + +export type Resolver = ( + validator: UnknownValidator, + validatorOptions?: ValidateOptions, +)=> RHFResolver diff --git a/yarn.lock b/yarn.lock index bcbd646e..ede56a66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6229,6 +6229,11 @@ tsutils@^3.17.1: dependencies: tslib "^1.8.1" +typanion@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/typanion/-/typanion-3.3.2.tgz#c31f3b2afb6e8ae74dbd3f96d5b1d8f9745e483e" + integrity sha512-m3v3wtFc6R0wtl0RpEn11bKXIOjS1zch5gmx0zg2G5qfGQ3A9TVZRMSL43O5eFuGXsrgzyvDcGRmSXGP5UqpDQ== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"