diff --git a/README.md b/README.md index 16a22980..43b72426 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Install your preferred validation library alongside `@hookform/resolvers`. - [effect-ts](#effect-ts) - [VineJS](#vinejs) - [fluentvalidation-ts](#fluentvalidation-ts) + - [standard-schema](#standard-schema) - [Backers](#backers) - [Sponsors](#sponsors) - [Contributors](#contributors) @@ -779,6 +780,72 @@ const App = () => { }; ``` +### [standard-schema](https://github.com/standard-schema/standard-schema) + +A standard interface for TypeScript schema validation libraries + +[![npm](https://img.shields.io/bundlephobia/minzip/@standard-schema/spec?style=for-the-badge)](https://bundlephobia.com/result?p=@standard-schema/spec) + +Example zod + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; +import { z } from 'zod'; + +const schema = z.object({ + name: z.string().min(1, { message: 'Required' }), + age: z.number().min(10), +}); + +const App = () => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: standardSchemaResolver(schema), + }); + + return ( +
console.log(d))}> + + {errors.name?.message &&

{errors.name?.message}

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

{errors.age?.message}

} + +
+ ); +}; +``` + +Example arkType + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; +import { type } from 'arktype'; + +const schema = type({ + username: 'string>1', + password: 'string>1', +}); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: standardSchemaResolver(schema), + }); + + return ( +
console.log(d))}> + + + +
+ ); +}; +``` + ## Backers Thanks go to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)]. diff --git a/bun.lockb b/bun.lockb index 9d0994bb..84b7b9bd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/node-13-exports.js b/config/node-13-exports.js index c60fd718..efab7b13 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -20,6 +20,7 @@ const subRepositories = [ 'effect-ts', 'vine', 'fluentvalidation-ts', + 'standard-schema', ]; const copySrc = () => { diff --git a/effect-ts/src/effect-ts.ts b/effect-ts/src/effect-ts.ts index b99a7584..91130bed 100644 --- a/effect-ts/src/effect-ts.ts +++ b/effect-ts/src/effect-ts.ts @@ -2,7 +2,7 @@ import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; import { Effect } from 'effect'; import { ArrayFormatter, decodeUnknown } from 'effect/ParseResult'; -import { appendErrors, type FieldError } from 'react-hook-form'; +import { type FieldError, appendErrors } from 'react-hook-form'; import type { Resolver } from './types'; export const effectTsResolver: Resolver = diff --git a/package.json b/package.json index 4463852c..b7f8352c 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,12 @@ "import": "./fluentvalidation-ts/dist/fluentvalidation-ts.mjs", "require": "./fluentvalidation-ts/dist/fluentvalidation-ts.js" }, + "./standard-schema": { + "types": "./standard-schema/dist/index.d.ts", + "umd": "./standard-schema/dist/standard-schema.umd.js", + "import": "./standard-schema/dist/standard-schema.mjs", + "require": "./standard-schema/dist/standard-schema.js" + }, "./package.json": "./package.json", "./*": "./*" }, @@ -184,7 +190,10 @@ "vine/dist", "fluentvalidation-ts/package.json", "fluentvalidation-ts/src", - "fluentvalidation-ts/dist" + "fluentvalidation-ts/dist", + "standard-schema/package.json", + "standard-schema/src", + "standard-schema/dist" ], "publishConfig": { "access": "public" @@ -211,6 +220,7 @@ "build:effect-ts": "microbundle --cwd effect-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,effect=Effect,effect/SchemaAST=EffectSchemaAST,effect/ParseResult=EffectParseResult", "build:vine": "microbundle --cwd vine --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@vinejs/vine=vine", "build:fluentvalidation-ts": "microbundle --cwd fluentvalidation-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", + "build:standard-schema": "microbundle --cwd standard-schema --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@standard-schema/spec=standardSchema", "postbuild": "node ./config/node-13-exports.js && check-export-map", "lint": "bunx @biomejs/biome check --write --vcs-use-ignore-file=true .", "lint:types": "tsc", @@ -243,7 +253,8 @@ "arktype", "typeschema", "vine", - "fluentvalidation-ts" + "fluentvalidation-ts", + "standard-schema" ], "repository": { "type": "git", @@ -257,6 +268,7 @@ "homepage": "https://react-hook-form.com", "devDependencies": { "@sinclair/typebox": "^0.34.15", + "@standard-schema/spec": "^1.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", diff --git a/standard-schema/package.json b/standard-schema/package.json new file mode 100644 index 00000000..de255f3d --- /dev/null +++ b/standard-schema/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hookform/resolvers/standard-schema", + "amdName": "hookformResolversStandardSchema", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: standard-schema", + "main": "dist/standard-schema.js", + "module": "dist/standard-schema.module.js", + "umd:main": "dist/standard-schema.umd.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0", + "@standard-schema/spec": "^1.0.0", + "@hookform/resolvers": "^2.0.0" + } +} diff --git a/standard-schema/src/__tests__/Form-native-validation.tsx b/standard-schema/src/__tests__/Form-native-validation.tsx new file mode 100644 index 00000000..2f965099 --- /dev/null +++ b/standard-schema/src/__tests__/Form-native-validation.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { type } from 'arktype'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { standardSchemaResolver } from '..'; + +const schema = type({ + username: 'string>1', + password: 'string>1', +}); + +type FormData = typeof schema.infer; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: standardSchemaResolver(schema), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with arkType", async () => { + const handleSubmit = vi.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 user.click(screen.getByText(/submit/i)); + + // username + usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(false); + expect(usernameField.validationMessage).toBe( + 'username must be at least length 2', + ); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(false); + expect(passwordField.validationMessage).toBe( + 'password must be at least length 2', + ); + + await user.type(screen.getByPlaceholderText(/username/i), 'joe'); + await 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/standard-schema/src/__tests__/Form.tsx b/standard-schema/src/__tests__/Form.tsx new file mode 100644 index 00000000..500a46d1 --- /dev/null +++ b/standard-schema/src/__tests__/Form.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { type } from 'arktype'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { standardSchemaResolver } from '..'; + +const schema = type({ + username: 'string>1', + password: 'string>1', +}); + +type FormData = typeof schema.infer & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: standardSchemaResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with arkType and TypeScript's integration", async () => { + const handleSubmit = vi.fn(); + render(); + + expect(screen.queryAllByRole('alert')).toHaveLength(0); + + await user.click(screen.getByText(/submit/i)); + + expect( + screen.getByText('username must be at least length 2'), + ).toBeInTheDocument(); + expect( + screen.getByText('password must be at least length 2'), + ).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/standard-schema/src/__tests__/__fixtures__/data.ts b/standard-schema/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..5f0495e7 --- /dev/null +++ b/standard-schema/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,65 @@ +import { type } from 'arktype'; +import { Field, InternalFieldName } from 'react-hook-form'; + +export const schema = type({ + username: 'string>2', + password: '/.*[A-Za-z].*/>8|/.*\\d.*/', + repeatPassword: 'string>1', + accessToken: 'string|number', + birthYear: '19001', + 'like?': type({ + id: 'number', + name: 'string>3', + }).array(), + dateStr: 'Date', +}); + +export const validData: typeof schema.infer = { + username: 'Doe', + password: 'Password123_', + repeatPassword: 'Password123_', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + url: 'https://react-hook-form.com/', + like: [ + { + id: 1, + name: 'name', + }, + ], + dateStr: new Date('2020-01-01'), +}; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: [{ id: 'z' }], + url: 'abc', +}; + +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/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap b/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap new file mode 100644 index 00000000..5000df6c --- /dev/null +++ b/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`standardSchemaResolver > should return a single error from standardSchemaResolver when validation fails 1`] = ` +{ + "errors": { + "accessToken": { + "message": "accessToken must be a number or a string (was missing)", + "ref": undefined, + }, + "birthYear": { + "message": "birthYear must be a number (was a string)", + "ref": undefined, + }, + "dateStr": { + "message": "dateStr must be a Date (was missing)", + "ref": undefined, + }, + "email": { + "message": "email must be an email address (was "")", + "ref": { + "name": "email", + }, + }, + "enabled": { + "message": "enabled must be boolean (was missing)", + "ref": undefined, + }, + "like": [ + { + "id": { + "message": "like[0].id must be a number (was a string)", + "ref": undefined, + }, + "name": { + "message": "like[0].name must be a string (was missing)", + "ref": undefined, + }, + }, + ], + "password": { + "message": "password must be matched by .*[A-Za-z].* or matched by .*\\d.* (was "___")", + "ref": { + "name": "password", + }, + }, + "repeatPassword": { + "message": "repeatPassword must be a string (was missing)", + "ref": undefined, + }, + "tags": { + "message": "tags must be an array (was missing)", + "ref": undefined, + }, + "username": { + "message": "username must be a string (was missing)", + "ref": { + "name": "username", + }, + }, + }, + "values": {}, +} +`; diff --git a/standard-schema/src/__tests__/standard-schema.ts b/standard-schema/src/__tests__/standard-schema.ts new file mode 100644 index 00000000..638d5d0d --- /dev/null +++ b/standard-schema/src/__tests__/standard-schema.ts @@ -0,0 +1,28 @@ +import { standardSchemaResolver } from '..'; +import { fields, invalidData, schema, validData } from './__fixtures__/data'; + +const shouldUseNativeValidation = false; + +describe('standardSchemaResolver', () => { + it('should return values from standardSchemaResolver when validation pass & raw=true', async () => { + const result = await standardSchemaResolver(schema)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from standardSchemaResolver when validation fails', async () => { + const result = await standardSchemaResolver(schema)( + invalidData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/standard-schema/src/index.ts b/standard-schema/src/index.ts new file mode 100644 index 00000000..5ee0d3cf --- /dev/null +++ b/standard-schema/src/index.ts @@ -0,0 +1,2 @@ +export * from './standard-schema'; +export * from './types'; diff --git a/standard-schema/src/standard-schema.ts b/standard-schema/src/standard-schema.ts new file mode 100644 index 00000000..12d9a9a1 --- /dev/null +++ b/standard-schema/src/standard-schema.ts @@ -0,0 +1,45 @@ +import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import { StandardSchemaV1 } from '@standard-schema/spec'; +import { FieldError } from 'react-hook-form'; +import type { Resolver } from './types'; + +const parseIssues = (issues: readonly StandardSchemaV1.Issue[]) => { + const errors: Record = {}; + + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + const path = issue.path?.join('.') ?? ''; + + if (path) { + if (!errors[path]) { + errors[path] = { message: issue.message } as FieldError; + } + } + } + + return errors; +}; + +export const standardSchemaResolver: Resolver = + (schema) => async (values, _, options) => { + let result = schema['~standard'].validate(values); + if (result instanceof Promise) { + result = await result; + } + + if (result.issues) { + const errors = parseIssues(result.issues); + + return { + values: {}, + errors: toNestErrors(errors, options), + }; + } + + options.shouldUseNativeValidation && validateFieldsNatively({}, options); + + return { + values: values, + errors: {}, + }; + }; diff --git a/standard-schema/src/types.ts b/standard-schema/src/types.ts new file mode 100644 index 00000000..c1b8d51e --- /dev/null +++ b/standard-schema/src/types.ts @@ -0,0 +1,10 @@ +import { StandardSchemaV1 } from '@standard-schema/spec'; +import { FieldValues, ResolverOptions, ResolverResult } from 'react-hook-form'; + +export type Resolver = >( + schema: T, +) => ( + values: TFieldValues, + context: TContext | undefined, + options: ResolverOptions, +) => Promise>;