From 5b16dd44703f98afae3bb6fa87c252e40f2b1852 Mon Sep 17 00:00:00 2001 From: Erik Shestopal Date: Mon, 31 Jul 2023 23:43:09 -0400 Subject: [PATCH] Add valibot resolver --- README.md | 68 +++++++++++++--- package.json | 12 ++- valibot/package.json | 18 +++++ valibot/pnpm-lock.yaml | 62 ++++++++++++++ valibot/src/__tests__/__fixtures__/data.ts | 80 +++++++++++++++++++ .../__tests__/__snapshots__/valibot.ts.snap | 79 ++++++++++++++++++ valibot/src/__tests__/valibot.ts | 24 ++++++ valibot/src/index.ts | 2 + valibot/src/types.ts | 22 +++++ valibot/src/valibot.ts | 76 ++++++++++++++++++ 10 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 valibot/package.json create mode 100644 valibot/pnpm-lock.yaml create mode 100644 valibot/src/__tests__/__fixtures__/data.ts create mode 100644 valibot/src/__tests__/__snapshots__/valibot.ts.snap create mode 100644 valibot/src/__tests__/valibot.ts create mode 100644 valibot/src/index.ts create mode 100644 valibot/src/types.ts create mode 100644 valibot/src/valibot.ts diff --git a/README.md b/README.md index 4a3117d5..0fe2b2e0 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,28 @@ ### Supported resolvers -- [Yup](#yup) -- [Zod](#zod) -- [Superstruct](#superstruct) -- [Joi](#joi) -- [Class Validator](#class-validator) -- [io-ts](#io-ts) -- [Nope](#nope) -- [computed-types](#computed-types) -- [typanion](#typanion) -- [Ajv](#ajv) -- [TypeBox](#typebox) -- [ArkType](#arktype) +- [Install](#install) +- [Links](#links) + - [Supported resolvers](#supported-resolvers) +- [API](#api) +- [Quickstart](#quickstart) + - [Yup](#yup) + - [Zod](#zod) + - [Superstruct](#superstruct) + - [Joi](#joi) + - [Vest](#vest) + - [Class Validator](#class-validator) + - [io-ts](#io-ts) + - [Nope](#nope) + - [computed-types](#computed-types) + - [typanion](#typanion) + - [Ajv](#ajv) + - [TypeBox](#typebox) + - [ArkType](#arktype) + - [Valibot](#valibot) +- [Backers](#backers) + - [Sponsors](#sponsors) +- [Contributors](#contributors) ## API @@ -532,6 +542,40 @@ const App = () => { }; ``` +### [Valibot](https://github.com/fabian-hiller/valibot) + +The modular and type safe schema library for validating structural data + +[![npm](https://img.shields.io/bundlephobia/minzip/valibot?style=for-the-badge)](https://bundlephobia.com/result?p=valibot) + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { valibotResolver } from '@hookform/resolvers/valibot'; +import { type } from 'arktype'; + +const schema = object({ + username: string([ + minLength(3, 'Needs to be at least 3 characters'), + endsWith('cool', 'Needs to end with `cool`'), + ]), + password: string(), +}); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: valibotResolver(schema), + }); + + return ( +
console.log(d))}> + + + +
+ ); +}; +``` + ## Backers Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)]. diff --git a/package.json b/package.json index 416f3350..6d9858e4 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,12 @@ "import": "./arktype/dist/arktype.mjs", "require": "./arktype/dist/arktype.js" }, + "./valibot": { + "types": "./valibot/dist/index.d.ts", + "umd": "./valibot/dist/valibot.umd.js", + "import": "./valibot/dist/valibot.mjs", + "require": "./valibot/dist/valibot.js" + }, "./package.json": "./package.json", "./*": "./*" }, @@ -136,7 +142,10 @@ "typebox/dist", "arktype/package.json", "arktype/src", - "arktype/dist" + "arktype/dist", + "valibot/package.json", + "valibot/src", + "valibot/dist" ], "publishConfig": { "access": "public" @@ -158,6 +167,7 @@ "build:ajv": "microbundle --cwd ajv --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", "build:typebox": "microbundle --cwd typebox --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@sinclair/typebox/value=value", "build:arktype": "microbundle --cwd arktype --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", + "build:valibot": "microbundle --cwd valibot --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", "postbuild": "node ./config/node-13-exports.js", "lint": "eslint . --ext .ts,.js --ignore-path .gitignore", "lint:types": "tsc", diff --git a/valibot/package.json b/valibot/package.json new file mode 100644 index 00000000..ae217d12 --- /dev/null +++ b/valibot/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hookform/resolvers/valibot", + "amdName": "hookformResolversValibot", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: valibot", + "main": "dist/valibot.js", + "module": "dist/valibot.module.js", + "umd:main": "dist/valibot.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", + "valibot": ">=0.8" + } +} diff --git a/valibot/pnpm-lock.yaml b/valibot/pnpm-lock.yaml new file mode 100644 index 00000000..2b1370a7 --- /dev/null +++ b/valibot/pnpm-lock.yaml @@ -0,0 +1,62 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@hookform/resolvers': + specifier: ^2.0.0 + version: 2.0.0(react-hook-form@7.0.0) + react-hook-form: + specifier: ^7.0.0 + version: 7.0.0(react@17.0.2) + valibot: + specifier: '>=0.8' + version: 0.8.0 + +packages: + + /@hookform/resolvers@2.0.0(react-hook-form@7.0.0): + resolution: {integrity: sha512-picG6qjP516JNblvg/wuDc8JPfdm3aNbtPbfu+PXQWFXp6ED6dyBL2IoB/tDvPFaNwwqUEwb6UYVKMIFAH+aqQ==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.0.0(react@17.0.2) + dev: false + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: false + + /react-hook-form@7.0.0(react@17.0.2): + resolution: {integrity: sha512-cXoKnNnQe5IxieesTu+enDS7Jfh8lyX8mSMwzNvC4L0k6R6yQ2F8rIhvBbzlFOGSoeEnsR64dvMZ1izWRCAw6A==} + peerDependencies: + react: ^16.8.0 || ^17 + dependencies: + react: 17.0.2 + dev: false + + /react@17.0.2: + resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + dev: false + + /valibot@0.8.0: + resolution: {integrity: sha512-wQHXVkVFj6Z0R3297icGCp3UX8S66onuIg03ihJ8aVgMn8pMJvYSfmrKb+aIg7w/Aw08togrklaMSqDP2vKtIA==} + dev: false diff --git a/valibot/src/__tests__/__fixtures__/data.ts b/valibot/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..3e2f642e --- /dev/null +++ b/valibot/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,80 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import { + object, + string, + minLength, + maxLength, + regex, + number, + minValue, + maxValue, + email, + array, + boolean, +} from 'valibot'; + +export const schema = object({ + username: string([minLength(2), maxLength(30), regex(/^\w+$/)]), + password: string('New Password is required', [ + 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', + ), + minLength(8, 'Must be at least 8 characters in length'), + ]), + repeatPassword: string(), + accessToken: string(), + birthYear: number([minValue(1900), maxValue(2013)]), + email: string([email()]), + tags: array(string()), + enabled: boolean(), + like: object({ + id: number(), + name: string([minLength(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/valibot/src/__tests__/__snapshots__/valibot.ts.snap b/valibot/src/__tests__/__snapshots__/valibot.ts.snap new file mode 100644 index 00000000..a51ba885 --- /dev/null +++ b/valibot/src/__tests__/__snapshots__/valibot.ts.snap @@ -0,0 +1,79 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`valibotResolver > should return a single error from valibotResolver when validation fails 1`] = ` +{ + "errors": { + "accessToken": { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + "birthYear": { + "message": "Invalid type", + "ref": undefined, + "type": "number", + }, + "email": { + "message": "Invalid email", + "ref": { + "name": "email", + }, + "type": "email", + }, + "enabled": { + "message": "Invalid type", + "ref": undefined, + "type": "boolean", + }, + "like": { + "id": { + "message": "Invalid type", + "ref": undefined, + "type": "number", + }, + "name": { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "regex", + }, + "repeatPassword": { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + "tags": [ + { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + { + "message": "Invalid type", + "ref": undefined, + "type": "string", + }, + ], + "username": { + "message": "Invalid type", + "ref": { + "name": "username", + }, + "type": "string", + }, + }, + "values": {}, +} +`; diff --git a/valibot/src/__tests__/valibot.ts b/valibot/src/__tests__/valibot.ts new file mode 100644 index 00000000..a7d5144d --- /dev/null +++ b/valibot/src/__tests__/valibot.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */ +import { valibotResolver } from '..'; +import { schema, validData, fields, invalidData } from './__fixtures__/data'; + +const shouldUseNativeValidation = false; +describe('valibotResolver', () => { + it('should return values from valibotResolver when validation pass', async () => { + const result = await valibotResolver(schema)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from valibotResolver when validation fails', async () => { + const result = await valibotResolver(schema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/valibot/src/index.ts b/valibot/src/index.ts new file mode 100644 index 00000000..ce0aecae --- /dev/null +++ b/valibot/src/index.ts @@ -0,0 +1,2 @@ +export * from './valibot'; +export * from './types'; diff --git a/valibot/src/types.ts b/valibot/src/types.ts new file mode 100644 index 00000000..97b8ffa0 --- /dev/null +++ b/valibot/src/types.ts @@ -0,0 +1,22 @@ +import { FieldValues, ResolverResult, ResolverOptions } from 'react-hook-form'; +import { BaseSchema, BaseSchemaAsync, ParseInfo } from 'valibot'; + +export type Resolver = ( + schema: T, + schemaOptions?: Partial>, + resolverOptions?: { + /** + * @default async + */ + mode?: 'sync' | 'async'; + /** + * Return the raw input values rather than the parsed values. + * @default false + */ + raw?: boolean; + }, +) => ( + values: TFieldValues, + context: TContext | undefined, + options: ResolverOptions, +) => Promise>; diff --git a/valibot/src/valibot.ts b/valibot/src/valibot.ts new file mode 100644 index 00000000..9569bd4f --- /dev/null +++ b/valibot/src/valibot.ts @@ -0,0 +1,76 @@ +import { toNestError } from '@hookform/resolvers'; +import type { Resolver } from './types'; +import { + BaseSchema, + BaseSchemaAsync, + ValiError, + parse, + parseAsync, +} from 'valibot'; +import { FieldErrors, FieldError } from 'react-hook-form'; + +type FlatErrors = Record; + +const parseErrors = (error: ValiError): FieldErrors => { + const errors = error.issues.reduce((flatErrors, issue) => { + if (issue.path) { + const path = issue.path.map(({ key }) => key).join('.'); + flatErrors[path] = [ + ...(flatErrors[path] || []), + { + message: issue.message, + type: issue.validation, + }, + ]; + } + + return flatErrors; + }, {}); + + return Object.entries(errors).reduce((acc, [path, errors]) => { + const [firstError] = errors; + acc[path] = { + message: firstError.message, + type: firstError.type, + }; + + return acc; + }, {}); +}; + +export const valibotResolver: Resolver = + ( + schema, + schemaOptions = { + abortEarly: false, + abortPipeEarly: false, + }, + resolverOptions = { + mode: 'async', + raw: false, + }, + ) => + async (values, _, options) => { + try { + const { mode, raw } = resolverOptions; + const parsed = + mode === 'sync' + ? parse(schema as BaseSchema, values, schemaOptions) + : await parseAsync( + schema as BaseSchema | BaseSchemaAsync, + values, + schemaOptions, + ); + + return { values: raw ? values : parsed, errors: {} as FieldErrors }; + } catch (error) { + if (error instanceof ValiError) { + return { + values: {}, + errors: toNestError(parseErrors(error), options), + }; + } + + throw error; + } + };