diff --git a/README.md b/README.md index e0d4ecb7..640967d5 100644 --- a/README.md +++ b/README.md @@ -552,14 +552,15 @@ The modular and type safe schema library for validating structural data ```typescript jsx import { useForm } from 'react-hook-form'; import { valibotResolver } from '@hookform/resolvers/valibot'; -import { object, string, minLength, endsWith } from 'valibot'; +import * as v from 'valibot'; -const schema = object({ - username: string('username is required', [ - minLength(3, 'Needs to be at least 3 characters'), - endsWith('cool', 'Needs to end with `cool`'), - ]), - password: string('password is required'), +const schema = v.object({ + username: v.pipe( + v.string('username is required'), + v.minLength(3, 'Needs to be at least 3 characters'), + v.endsWith('cool', 'Needs to end with `cool`'), + ), + password: v.string('password is required'), }); const App = () => { @@ -581,7 +582,7 @@ const App = () => { A powerful TypeScript framework that provides a fully-fledged functional effect system with a rich standard library. -[![npm](https://img.shields.io/bundlephobia/minzip/valibot?style=for-the-badge)](https://bundlephobia.com/result?p=effect) +[![npm](https://img.shields.io/bundlephobia/minzip/@effect/schema?style=for-the-badge)](https://bundlephobia.com/result?p=effect) ```typescript jsx import React from 'react'; diff --git a/package.json b/package.json index 075a250d..6edee975 100644 --- a/package.json +++ b/package.json @@ -262,7 +262,7 @@ "superstruct": "^1.0.3", "typanion": "^3.14.0", "typescript": "^5.1.6", - "valibot": "^0.24.1", + "valibot": "0.31.0-rc.12", "vest": "^4.6.11", "vite": "^4.4.9", "vite-tsconfig-paths": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 035e902a..00d6ee4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,8 +135,8 @@ importers: specifier: ^5.1.6 version: 5.1.6 valibot: - specifier: ^0.24.1 - version: 0.24.1 + specifier: 0.31.0-rc.12 + version: 0.31.0-rc.12 vest: specifier: ^4.6.11 version: 4.6.11 @@ -3478,8 +3478,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - valibot@0.24.1: - resolution: {integrity: sha512-Toclbuy20XsECZiueh2dkQ63he2AGaBIj/FJRDAFti2kueFldm9bjJzSYvPaL5CE1HXDMRhq7olak8at7xCz5A==} + valibot@0.31.0-rc.12: + resolution: {integrity: sha512-vAI18A65Og1BwuVqC3Zvr0+yNxOANLHDVo3r5VOTsVItfBl6KcykmY7fioSU5CVSlrj2JgViHjcsfAofO2h0rw==} validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -7254,7 +7254,7 @@ snapshots: util-deprecate@1.0.2: {} - valibot@0.24.1: {} + valibot@0.31.0-rc.12: {} validate-npm-package-license@3.0.4: dependencies: diff --git a/valibot/package.json b/valibot/package.json index ae217d12..2b0733f2 100644 --- a/valibot/package.json +++ b/valibot/package.json @@ -11,8 +11,8 @@ "types": "dist/index.d.ts", "license": "MIT", "peerDependencies": { - "react-hook-form": "^7.0.0", "@hookform/resolvers": "^2.0.0", - "valibot": ">=0.8" + "react-hook-form": "^7.0.0", + "valibot": ">=0.31.0 <1" } } diff --git a/valibot/src/__tests__/Form-native-validation.tsx b/valibot/src/__tests__/Form-native-validation.tsx index 78b60b4c..2e7c4f49 100644 --- a/valibot/src/__tests__/Form-native-validation.tsx +++ b/valibot/src/__tests__/Form-native-validation.tsx @@ -2,22 +2,22 @@ import React from 'react'; import { useForm } from 'react-hook-form'; import { render, screen } from '@testing-library/react'; import user from '@testing-library/user-event'; -import { string, required, object, minLength } from 'valibot'; +import * as v from 'valibot'; import { valibotResolver } from '..'; -const USERNAME_REQUIRED_MESSAGE = 'username field is required'; -const PASSWORD_REQUIRED_MESSAGE = 'password field is required'; - -const schema = required( - object({ - username: string(USERNAME_REQUIRED_MESSAGE, [ - minLength(2, USERNAME_REQUIRED_MESSAGE), - ]), - password: string(PASSWORD_REQUIRED_MESSAGE, [ - minLength(2, PASSWORD_REQUIRED_MESSAGE), - ]), - }), -); +const USERNAME_REQUIRED_MESSAGE = 'username field is v.required'; +const PASSWORD_REQUIRED_MESSAGE = 'password field is v.required'; + +const schema = v.object({ + username: v.pipe( + v.string(USERNAME_REQUIRED_MESSAGE), + v.minLength(2, USERNAME_REQUIRED_MESSAGE), + ), + password: v.pipe( + v.string(PASSWORD_REQUIRED_MESSAGE), + v.minLength(2, PASSWORD_REQUIRED_MESSAGE), + ), +}); type FormData = { username: string; password: string }; diff --git a/valibot/src/__tests__/Form.tsx b/valibot/src/__tests__/Form.tsx index 058a9c87..eb66bb6d 100644 --- a/valibot/src/__tests__/Form.tsx +++ b/valibot/src/__tests__/Form.tsx @@ -2,22 +2,22 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import user from '@testing-library/user-event'; import { useForm } from 'react-hook-form'; -import { string, required, object, minLength } from 'valibot'; +import * as v from 'valibot'; import { valibotResolver } from '..'; const USERNAME_REQUIRED_MESSAGE = 'username field is required'; const PASSWORD_REQUIRED_MESSAGE = 'password field is required'; -const schema = required( - object({ - username: string(USERNAME_REQUIRED_MESSAGE, [ - minLength(2, USERNAME_REQUIRED_MESSAGE), - ]), - password: string(PASSWORD_REQUIRED_MESSAGE, [ - minLength(2, PASSWORD_REQUIRED_MESSAGE), - ]), - }), -); +const schema = v.object({ + username: v.pipe( + v.string(USERNAME_REQUIRED_MESSAGE), + v.minLength(2, USERNAME_REQUIRED_MESSAGE), + ), + password: v.pipe( + v.string(PASSWORD_REQUIRED_MESSAGE), + v.minLength(2, PASSWORD_REQUIRED_MESSAGE), + ), +}); type FormData = { username: string; password: string }; diff --git a/valibot/src/__tests__/__fixtures__/data.ts b/valibot/src/__tests__/__fixtures__/data.ts index 2d8a1568..7d99d6a4 100644 --- a/valibot/src/__tests__/__fixtures__/data.ts +++ b/valibot/src/__tests__/__fixtures__/data.ts @@ -1,62 +1,52 @@ import { Field, InternalFieldName } from 'react-hook-form'; -import { - object, - string, - minLength, - maxLength, - regex, - number, - minValue, - maxValue, - email, - array, - boolean, - required, - union, - variant, - literal, -} from 'valibot'; +import * as v from 'valibot'; -export const schema = required( - 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('Repeat Password is required'), - accessToken: union( - [ - string('Access token should be a string'), - number('Access token should be a number'), - ], - 'access token is required', +export const schema = v.object({ + username: v.pipe( + v.string(), + v.minLength(2), + v.maxLength(30), + v.regex(/^\w+$/), + ), + password: v.pipe( + v.string('New Password is required'), + v.regex(new RegExp('.*[A-Z].*'), 'One uppercase character'), + v.regex(new RegExp('.*[a-z].*'), 'One lowercase character'), + v.regex(new RegExp('.*\\d.*'), 'One number'), + v.regex( + new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), + 'One special character', ), - birthYear: number('Please enter your birth year', [ - minValue(1900), - maxValue(2013), - ]), - email: string([email('Invalid email address')]), - tags: array(string('Tags should be strings')), - enabled: boolean(), - like: required( - object({ - id: number('Like id is required'), - name: string('Like name is required', [minLength(4, 'Too short')]), - }), + v.minLength(8, 'Must be at least 8 characters in length'), + ), + repeatPassword: v.string('Repeat Password is required'), + accessToken: v.union( + [ + v.string('Access token should be a string'), + v.number('Access token should be a number'), + ], + 'access token is required', + ), + birthYear: v.pipe( + v.number('Please enter your birth year'), + v.minValue(1900), + v.maxValue(2013), + ), + email: v.pipe(v.string(), v.email('Invalid email address')), + tags: v.array(v.string('Tags should be strings')), + enabled: v.boolean(), + like: v.object({ + id: v.number('Like id is required'), + name: v.pipe( + v.string('Like name is required'), + v.minLength(4, 'Too short'), ), }), -); +}); -export const schemaError = variant('type', [ - object({ type: literal('a') }), - object({ type: literal('b') }), +export const schemaError = v.variant('type', [ + v.object({ type: v.literal('a') }), + v.object({ type: v.literal('b') }), ]); export const validSchemaErrorData = { type: 'a' }; diff --git a/valibot/src/__tests__/__snapshots__/valibot.ts.snap b/valibot/src/__tests__/__snapshots__/valibot.ts.snap index bf5ed90e..2de4b784 100644 --- a/valibot/src/__tests__/__snapshots__/valibot.ts.snap +++ b/valibot/src/__tests__/__snapshots__/valibot.ts.snap @@ -4,9 +4,9 @@ exports[`valibotResolver > should return a single error from valibotResolver whe { "errors": { "accessToken": { - "message": "Invalid type", + "message": "access token is required", "ref": undefined, - "type": "non_optional", + "type": "union", }, "birthYear": { "message": "Please enter your birth year", @@ -21,9 +21,9 @@ exports[`valibotResolver > should return a single error from valibotResolver whe "type": "email", }, "enabled": { - "message": "Invalid type", + "message": "Invalid type: Expected boolean but received undefined", "ref": undefined, - "type": "non_optional", + "type": "boolean", }, "like": { "id": { @@ -32,9 +32,9 @@ exports[`valibotResolver > should return a single error from valibotResolver whe "type": "number", }, "name": { - "message": "Invalid type", + "message": "Like name is required", "ref": undefined, - "type": "non_optional", + "type": "string", }, }, "password": { @@ -45,9 +45,9 @@ exports[`valibotResolver > should return a single error from valibotResolver whe "type": "regex", }, "repeatPassword": { - "message": "Invalid type", + "message": "Repeat Password is required", "ref": undefined, - "type": "non_optional", + "type": "string", }, "tags": [ { @@ -67,11 +67,11 @@ exports[`valibotResolver > should return a single error from valibotResolver whe }, ], "username": { - "message": "Invalid type", + "message": "Invalid type: Expected string but received undefined", "ref": { "name": "username", }, - "type": "non_optional", + "type": "string", }, }, "values": {}, @@ -82,9 +82,9 @@ exports[`valibotResolver > should return a single error from valibotResolver wit { "errors": { "accessToken": { - "message": "Invalid type", + "message": "access token is required", "ref": undefined, - "type": "non_optional", + "type": "union", }, "birthYear": { "message": "Please enter your birth year", @@ -99,9 +99,9 @@ exports[`valibotResolver > should return a single error from valibotResolver wit "type": "email", }, "enabled": { - "message": "Invalid type", + "message": "Invalid type: Expected boolean but received undefined", "ref": undefined, - "type": "non_optional", + "type": "boolean", }, "like": { "id": { @@ -110,9 +110,9 @@ exports[`valibotResolver > should return a single error from valibotResolver wit "type": "number", }, "name": { - "message": "Invalid type", + "message": "Like name is required", "ref": undefined, - "type": "non_optional", + "type": "string", }, }, "password": { @@ -123,9 +123,9 @@ exports[`valibotResolver > should return a single error from valibotResolver wit "type": "regex", }, "repeatPassword": { - "message": "Invalid type", + "message": "Repeat Password is required", "ref": undefined, - "type": "non_optional", + "type": "string", }, "tags": [ { @@ -145,11 +145,11 @@ exports[`valibotResolver > should return a single error from valibotResolver wit }, ], "username": { - "message": "Invalid type", + "message": "Invalid type: Expected string but received undefined", "ref": { "name": "username", }, - "type": "non_optional", + "type": "string", }, }, "values": {}, @@ -160,11 +160,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe { "errors": { "accessToken": { - "message": "Invalid type", + "message": "access token is required", "ref": undefined, - "type": "non_optional", + "type": "union", "types": { - "non_optional": "Invalid type", + "union": "access token is required", }, }, "birthYear": { @@ -186,11 +186,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "enabled": { - "message": "Invalid type", + "message": "Invalid type: Expected boolean but received undefined", "ref": undefined, - "type": "non_optional", + "type": "boolean", "types": { - "non_optional": "Invalid type", + "boolean": "Invalid type: Expected boolean but received undefined", }, }, "like": { @@ -203,11 +203,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "name": { - "message": "Invalid type", + "message": "Like name is required", "ref": undefined, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Like name is required", }, }, }, @@ -227,11 +227,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "repeatPassword": { - "message": "Invalid type", + "message": "Repeat Password is required", "ref": undefined, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Repeat Password is required", }, }, "tags": [ @@ -261,13 +261,13 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, ], "username": { - "message": "Invalid type", + "message": "Invalid type: Expected string but received undefined", "ref": { "name": "username", }, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Invalid type: Expected string but received undefined", }, }, }, @@ -279,11 +279,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe { "errors": { "accessToken": { - "message": "Invalid type", + "message": "access token is required", "ref": undefined, - "type": "non_optional", + "type": "union", "types": { - "non_optional": "Invalid type", + "union": "access token is required", }, }, "birthYear": { @@ -305,11 +305,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "enabled": { - "message": "Invalid type", + "message": "Invalid type: Expected boolean but received undefined", "ref": undefined, - "type": "non_optional", + "type": "boolean", "types": { - "non_optional": "Invalid type", + "boolean": "Invalid type: Expected boolean but received undefined", }, }, "like": { @@ -322,11 +322,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "name": { - "message": "Invalid type", + "message": "Like name is required", "ref": undefined, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Like name is required", }, }, }, @@ -346,11 +346,11 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, }, "repeatPassword": { - "message": "Invalid type", + "message": "Repeat Password is required", "ref": undefined, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Repeat Password is required", }, }, "tags": [ @@ -380,13 +380,13 @@ exports[`valibotResolver > should return all the errors from valibotResolver whe }, ], "username": { - "message": "Invalid type", + "message": "Invalid type: Expected string but received undefined", "ref": { "name": "username", }, - "type": "non_optional", + "type": "string", "types": { - "non_optional": "Invalid type", + "string": "Invalid type: Expected string but received undefined", }, }, }, diff --git a/valibot/src/__tests__/valibot.ts b/valibot/src/__tests__/valibot.ts index ea89cc9c..8651b6eb 100644 --- a/valibot/src/__tests__/valibot.ts +++ b/valibot/src/__tests__/valibot.ts @@ -21,15 +21,13 @@ describe('valibotResolver', () => { ...a, }; }); - const parseSpy = vi.spyOn(valibot, 'parse'); - const parseAsyncSpy = vi.spyOn(valibot, 'parseAsync'); + const funcSpy = vi.spyOn(valibot, 'safeParseAsync'); const result = await valibotResolver(schema, undefined, { mode: 'sync', })(validData, undefined, { fields, shouldUseNativeValidation }); - expect(parseSpy).toHaveBeenCalledTimes(1); - expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(funcSpy).toHaveBeenCalledTimes(1); expect(result.errors).toEqual({}); expect(result).toMatchSnapshot(); }); @@ -42,15 +40,13 @@ describe('valibotResolver', () => { ...a, }; }); - const parseSpy = vi.spyOn(valibot, 'parse'); - const parseAsyncSpy = vi.spyOn(valibot, 'parseAsync'); + const funcSpy = vi.spyOn(valibot, 'safeParseAsync'); const result = await valibotResolver(schema, undefined, { mode: 'sync', })(invalidData, undefined, { fields, shouldUseNativeValidation }); - expect(parseSpy).toHaveBeenCalledTimes(1); - expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(funcSpy).toHaveBeenCalledTimes(1); expect(result).toMatchSnapshot(); }); @@ -107,7 +103,7 @@ describe('valibotResolver', () => { expect(result).toMatchSnapshot(); }); - it('should be able to validate variants', async () => { + it('should be able to validate variants without errors', async () => { const result = await valibotResolver(schemaError, undefined, { mode: 'sync', })(validSchemaErrorData, undefined, { @@ -123,7 +119,7 @@ describe('valibotResolver', () => { }); }); - it('should exit issue resolution if no path is set', async () => { + it('should be able to validate variants with errors', async () => { const result = await valibotResolver(schemaError, undefined, { mode: 'sync', })(invalidSchemaErrorData, undefined, { @@ -132,7 +128,13 @@ describe('valibotResolver', () => { }); expect(result).toEqual({ - errors: {}, + errors: { + type: { + message: 'Invalid type: Expected "a" | "b" but received "c"', + ref: undefined, + type: 'variant', + }, + }, values: {}, }); }); diff --git a/valibot/src/types.ts b/valibot/src/types.ts index 97b8ffa0..5f192cb0 100644 --- a/valibot/src/types.ts +++ b/valibot/src/types.ts @@ -1,9 +1,21 @@ import { FieldValues, ResolverResult, ResolverOptions } from 'react-hook-form'; -import { BaseSchema, BaseSchemaAsync, ParseInfo } from 'valibot'; +import { + BaseIssue, + BaseSchema, + BaseSchemaAsync, + Config, + InferIssue, +} from 'valibot'; -export type Resolver = ( +export type Resolver = < + T extends + | BaseSchema> + | BaseSchemaAsync>, +>( schema: T, - schemaOptions?: Partial>, + schemaOptions?: Partial< + Omit>, 'abortPipeEarly' | 'skipPipe'> + >, resolverOptions?: { /** * @default async diff --git a/valibot/src/valibot.ts b/valibot/src/valibot.ts index ca294778..6fb15733 100644 --- a/valibot/src/valibot.ts +++ b/valibot/src/valibot.ts @@ -1,89 +1,67 @@ import { toNestErrors } from '@hookform/resolvers'; +import { FieldError, appendErrors, FieldValues } from 'react-hook-form'; +import { getDotPath, safeParseAsync } from 'valibot'; import type { Resolver } from './types'; -import { - BaseSchema, - BaseSchemaAsync, - ValiError, - parse, - parseAsync, -} from 'valibot'; -import { FieldErrors, FieldError, appendErrors } from 'react-hook-form'; -const parseErrors = ( - valiErrors: ValiError, - validateAllFieldCriteria: boolean, -): FieldErrors => { - const errors: Record = {}; - - for (const error of valiErrors.issues) { - if (!error.path) { - continue; - } - const _path = error.path.map(({ key }) => key).join('.'); - - if (!errors[_path]) { - errors[_path] = { message: error.message, type: error.validation }; - } - - if (validateAllFieldCriteria) { - const types = errors[_path].types; - const messages = types && types[error.validation]; - - errors[_path] = appendErrors( - _path, - validateAllFieldCriteria, - errors, - error.validation, - messages - ? ([] as string[]).concat(messages as string[], error.message) - : error.message, - ) as FieldError; - } - } - - return errors; -}; export const valibotResolver: Resolver = (schema, schemaOptions, resolverOptions = {}) => async (values, _, options) => { - try { - const schemaOpts = Object.assign( - {}, - { - abortEarly: false, - abortPipeEarly: false, - }, - schemaOptions, - ); + // Check if we should validate all field criteria + const validateAllFieldCriteria = + !options.shouldUseNativeValidation && options.criteriaMode === 'all'; - const parsed = - resolverOptions.mode === 'sync' - ? parse(schema as BaseSchema, values, schemaOpts) - : await parseAsync( - schema as BaseSchema | BaseSchemaAsync, - values, - schemaOpts, - ); + // Parse values with Valibot schema + const result = await safeParseAsync(schema, values, { + ...schemaOptions, + abortPipeEarly: !validateAllFieldCriteria, + }); - return { - values: resolverOptions.raw ? values : parsed, - errors: {} as FieldErrors, - }; - } catch (error) { - if (error instanceof ValiError) { - return { - values: {}, - errors: toNestErrors( - parseErrors( - error, - !options.shouldUseNativeValidation && - options.criteriaMode === 'all', - ), - options, - ), - }; + // If there are issues, return them as errors + if (result.issues) { + // Create errors object + const errors: Record = {}; + + // Iterate over issues to add them to errors object + for (const issue of result.issues) { + // Create dot path from issue + const path = getDotPath(issue); + + if (path) { + // Add first error of path to errors object + if (!errors[path]) { + errors[path] = { message: issue.message, type: issue.type }; + } + + // If configured, add all errors of path to errors object + if (validateAllFieldCriteria) { + const types = errors[path].types; + const messages = types && types[issue.type]; + errors[path] = appendErrors( + path, + validateAllFieldCriteria, + errors, + issue.type, + messages + ? ([] as string[]).concat( + messages as string | string[], + issue.message, + ) + : issue.message, + ) as FieldError; + } + } } - throw error; + // Return resolver result with errors + return { + values: {}, + errors: toNestErrors(errors, options), + } as const; } + + // Otherwise, return resolver result with values + return { + values: resolverOptions.raw ? values : (result.output as FieldValues), + errors: {}, + }; };