diff --git a/README.md b/README.md index e42e330e..fc762459 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ - [ArkType](#arktype) - [Valibot](#valibot) - [effect-ts](#effect-ts) + - [VineJS](#vinejs) - [Backers](#backers) - [Sponsors](#sponsors) - [Contributors](#contributors) @@ -629,6 +630,41 @@ function TestComponent({ onSubmit }: Props) { } ``` +### [VineJS](https://github.com/vinejs/vine) + +VineJS is a form data validation library for Node.js + +[![npm](https://img.shields.io/bundlephobia/minzip/@vinejs/vine?style=for-the-badge)](https://bundlephobia.com/result?p=@vinejs/vine) + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { vineResolver } from '@hookform/resolvers/vine'; +import vine from '@vinejs/vine'; + +const schema = vine.compile( + vine.object({ + username: vine.string().minLength(1), + password: vine.string().minLength(1), + }), +); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: vineResolver(schema), + }); + + return ( +
console.log(d))}> + + {errors.username && {errors.username.message}} + + {errors.password && {errors.password.message}} + +
+ ); +}; +``` + ## Backers Thanks go 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 d7f8ccd5..173155d2 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -17,6 +17,7 @@ const subRepositories = [ 'arktype', 'valibot', 'effect-ts', + 'vine', ]; const copySrc = () => { diff --git a/package.json b/package.json index c553687a..e22104eb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@hookform/resolvers", "amdName": "hookformResolvers", "version": "2.9.1", - "description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts, Nope, computed-types, TypeBox, arktype, Typanion and Effect-TS", + "description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts, Nope, computed-types, TypeBox, arktype, Typanion, Effect-TS and VineJS", "main": "dist/resolvers.js", "module": "dist/resolvers.module.js", "umd:main": "dist/resolvers.umd.js", @@ -105,6 +105,12 @@ "import": "./effect-ts/dist/effect-ts.mjs", "require": "./effect-ts/dist/effect-ts.js" }, + "./vine": { + "types": "./vine/dist/index.d.ts", + "umd": "./vine/dist/vine.umd.js", + "import": "./vine/dist/vine.mjs", + "require": "./vine/dist/vine.js" + }, "./package.json": "./package.json", "./*": "./*" }, @@ -154,7 +160,10 @@ "valibot/dist", "effect-ts/package.json", "effect-ts/src", - "effect-ts/dist" + "effect-ts/dist", + "vine/package.json", + "vine/src", + "vine/dist" ], "publishConfig": { "access": "public" @@ -178,6 +187,7 @@ "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", "build:effect-ts": "microbundle --cwd effect-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@effect/schema=EffectSchema,@effect/schema/AST=EffectSchemaAST,@effect/schema/ArrayFormatter=EffectSchemaArrayFormatter", + "build:vine": "microbundle --cwd vine --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@vinejs/vine=vine", "postbuild": "node ./config/node-13-exports.js", "lint": "eslint . --ext .ts,.js --ignore-path .gitignore", "lint:types": "tsc", @@ -207,7 +217,8 @@ "typanion", "ajv", "TypeBox", - "arktype" + "arktype", + "vine" ], "repository": { "type": "git", @@ -230,6 +241,7 @@ "@types/react": "^18.2.20", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", + "@vinejs/vine": "^2.0.0", "@vitejs/plugin-react": "^4.0.4", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 551f218b..fe537467 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.4.1 version: 6.4.1(eslint@8.47.0)(typescript@5.1.6) + '@vinejs/vine': + specifier: ^2.0.0 + version: 2.0.0 '@vitejs/plugin-react': specifier: ^4.0.4 version: 4.0.4(vite@4.4.9(@types/node@20.5.2)(terser@5.19.2)) @@ -1024,6 +1027,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@poppinss/macroable@1.0.2': + resolution: {integrity: sha512-xhhEcEvhQC8mP5oOr5hbE4CmUgmw/IPV1jhpGg2xSkzoFrt9i8YVqBQt9744EFesi5F7pBheWozg63RUBM/5JA==} + engines: {node: '>=18.16.0'} + '@rollup/plugin-alias@3.1.9': resolution: {integrity: sha512-QI5fsEvm9bDzt32k39wpOwZhVzRcL5ydcffUHMyLVaVaLeC70I8TJZ17F1z1eMoLu4E/UOcH9BWVkKpIKdrfiw==} engines: {node: '>=8.0.0'} @@ -1174,6 +1181,9 @@ packages: '@types/validator@13.11.1': resolution: {integrity: sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A==} + '@types/validator@13.11.10': + resolution: {integrity: sha512-e2PNXoXLr6Z+dbfx5zSh9TRlXJrELycxiaXznp4S5+D2M3b9bqJEitNHA5923jhnB2zzFiZHa2f0SI1HoIahpg==} + '@typescript-eslint/eslint-plugin@6.4.1': resolution: {integrity: sha512-3F5PtBzUW0dYlq77Lcqo13fv+58KDwUib3BddilE8ajPJT+faGgxmI9Sw+I8ZS22BYwoir9ZhNXcLi+S+I2bkw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1232,6 +1242,14 @@ packages: resolution: {integrity: sha512-y/TyRJsbZPkJIZQXrHfdnxVnxyKegnpEvnRGNam7s3TRR2ykGefEWOhaef00/UUN3IZxizS7BTO3svd3lCOJRQ==} engines: {node: ^16.0.0 || >=18.0.0} + '@vinejs/compiler@2.5.0': + resolution: {integrity: sha512-hg4ekaB5Y2zh+IWzBiC/WCDWrIfpVnKu/ubUvelKlidc/VbulsexoFRw5kJGHZenPVI5YzNnDeTdYSALkTV7jQ==} + engines: {node: '>=18.0.0'} + + '@vinejs/vine@2.0.0': + resolution: {integrity: sha512-NqgT4B2uo4mMsGI8LJdpuXNnan7F3xm10+kHaXpqI0PCYpn7+Xiic6av586mmj747/qZ3iR8o4C9cL54WU1fWw==} + engines: {node: '>=18.16.0'} + '@vitejs/plugin-react@4.0.4': resolution: {integrity: sha512-7wU921ABnNYkETiMaZy7XqpueMnpu5VxvVps13MjmCo+utBdD79sZzrApHawHtVX66cCJQQTXFcjH0y9dSUK8g==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1441,6 +1459,10 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -1626,6 +1648,9 @@ packages: resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} engines: {node: '>=14'} + dayjs@1.11.11: + resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==} + debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -1676,6 +1701,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -2508,6 +2536,10 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + normalize-url@8.0.1: + resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} + engines: {node: '>=14.16'} + npm-run-all@4.1.5: resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} engines: {node: '>= 4'} @@ -4622,6 +4654,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 + '@poppinss/macroable@1.0.2': {} + '@rollup/plugin-alias@3.1.9(rollup@2.79.1)': dependencies: rollup: 2.79.1 @@ -4771,6 +4805,8 @@ snapshots: '@types/validator@13.11.1': {} + '@types/validator@13.11.10': {} + '@typescript-eslint/eslint-plugin@6.4.1(@typescript-eslint/parser@6.4.1(eslint@8.47.0)(typescript@5.1.6))(eslint@8.47.0)(typescript@5.1.6)': dependencies: '@eslint-community/regexpp': 4.7.0 @@ -4856,6 +4892,19 @@ snapshots: '@typescript-eslint/types': 6.4.1 eslint-visitor-keys: 3.4.3 + '@vinejs/compiler@2.5.0': {} + + '@vinejs/vine@2.0.0': + dependencies: + '@poppinss/macroable': 1.0.2 + '@types/validator': 13.11.10 + '@vinejs/compiler': 2.5.0 + camelcase: 8.0.0 + dayjs: 1.11.11 + dlv: 1.1.3 + normalize-url: 8.0.1 + validator: 13.11.0 + '@vitejs/plugin-react@4.0.4(vite@4.4.9(@types/node@20.5.2)(terser@5.19.2))': dependencies: '@babel/core': 7.22.10 @@ -5083,6 +5132,8 @@ snapshots: camelcase@6.3.0: {} + camelcase@8.0.0: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.21.10 @@ -5313,6 +5364,8 @@ snapshots: whatwg-mimetype: 3.0.0 whatwg-url: 12.0.1 + dayjs@1.11.11: {} + debug@4.3.4: dependencies: ms: 2.1.2 @@ -5365,6 +5418,8 @@ snapshots: dependencies: path-type: 4.0.0 + dlv@1.1.3: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -6326,6 +6381,8 @@ snapshots: normalize-url@6.1.0: {} + normalize-url@8.0.1: {} + npm-run-all@4.1.5: dependencies: ansi-styles: 3.2.1 diff --git a/vine/package.json b/vine/package.json new file mode 100644 index 00000000..473737df --- /dev/null +++ b/vine/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hookform/resolvers/vine", + "amdName": "hookformResolversVine", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: vine", + "main": "dist/vine.js", + "module": "dist/vine.module.js", + "umd:main": "dist/vine.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", + "@vinejs/vine": "^2.0.0" + } +} diff --git a/vine/src/__tests__/Form-native-validation.tsx b/vine/src/__tests__/Form-native-validation.tsx new file mode 100644 index 00000000..e7ec4a4b --- /dev/null +++ b/vine/src/__tests__/Form-native-validation.tsx @@ -0,0 +1,81 @@ +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 { vineResolver } from '..'; +import vine from '@vinejs/vine'; +import { Infer } from '@vinejs/vine/build/src/types'; + +const schema = vine.compile( + vine.object({ + username: vine.string().minLength(1), + password: vine.string().minLength(1), + }), +); + +type FormData = Infer & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: vineResolver(schema), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with Zod", 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('The username field must have at least 1 characters'); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(false); + expect(passwordField.validationMessage).toBe('The password field must have at least 1 characters'); + + 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/vine/src/__tests__/Form.tsx b/vine/src/__tests__/Form.tsx new file mode 100644 index 00000000..736f9c4d --- /dev/null +++ b/vine/src/__tests__/Form.tsx @@ -0,0 +1,55 @@ +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 { vineResolver } from '..'; +import vine from '@vinejs/vine'; +import { Infer } from '@vinejs/vine/build/src/types'; + +const schema = vine.compile( + vine.object({ + username: vine.string().minLength(1), + password: vine.string().minLength(1), + }), +); + +type FormData = Infer & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: vineResolver(schema), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Vine 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(/The username field must have at least 1 characters/i)).toBeInTheDocument(); + expect(screen.getByText(/The password field must have at least 1 characters/i)).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/vine/src/__tests__/__fixtures__/data.ts b/vine/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..97717525 --- /dev/null +++ b/vine/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,74 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import vine from '@vinejs/vine'; +import { Infer } from '@vinejs/vine/build/src/types'; + +export const schema = vine.compile( + vine.object({ + username: vine.string().regex(/^\w+$/).minLength(3).maxLength(30), + password: vine + .string() + .regex(new RegExp('.*[A-Z].*')) + .regex(new RegExp('.*[a-z].*')) + .regex(new RegExp('.*\\d.*')) + .regex(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*')) + .minLength(8) + .confirmed({ confirmationField: 'repeatPassword' }), + repeatPassword: vine.string().sameAs('password'), + accessToken: vine.unionOfTypes([vine.string(), vine.number()]), + birthYear: vine.number().min(1900).max(2013), + email: vine.string().email().optional(), + tags: vine.array(vine.string()), + enabled: vine.boolean(), + like: vine.array( + vine.object({ + id: vine.number(), + name: vine.string().fixedLength(4), + }), + ), + dateStr: vine.string().transform((value: string) => new Date(value).toISOString()), + }), +); + +export const validData: Infer = { + username: 'Doe', + password: 'Password123_', + repeatPassword: 'Password123_', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + like: [ + { + id: 1, + name: 'name', + }, + ], + dateStr: '2020-01-01T00:00:00.000Z', +}; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: [{ id: 'z' }], +}; + +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/vine/src/__tests__/__snapshots__/vine.ts.snap b/vine/src/__tests__/__snapshots__/vine.ts.snap new file mode 100644 index 00000000..b139736b --- /dev/null +++ b/vine/src/__tests__/__snapshots__/vine.ts.snap @@ -0,0 +1,180 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`vineResolver > should return a single error from vineResolver when validation fails 1`] = ` +{ + "errors": { + "accessToken": { + "message": "Invalid value provided for accessToken field", + "ref": undefined, + "type": "unionOfTypes", + }, + "birthYear": { + "message": "The birthYear field must be a number", + "ref": undefined, + "type": "number", + }, + "dateStr": { + "message": "The dateStr field must be defined", + "ref": undefined, + "type": "required", + }, + "email": { + "message": "The email field must be a valid email address", + "ref": { + "name": "email", + }, + "type": "email", + }, + "enabled": { + "message": "The enabled field must be defined", + "ref": undefined, + "type": "required", + }, + "like": [ + { + "id": { + "message": "The id field must be a number", + "ref": undefined, + "type": "number", + }, + "name": { + "message": "The name field must be defined", + "ref": undefined, + "type": "required", + }, + }, + ], + "password": { + "message": "The password field format is invalid", + "ref": { + "name": "password", + }, + "type": "regex", + }, + "repeatPassword": { + "message": "The repeatPassword field must be defined", + "ref": undefined, + "type": "required", + }, + "tags": { + "message": "The tags field must be defined", + "ref": undefined, + "type": "required", + }, + "username": { + "message": "The username field must be defined", + "ref": { + "name": "username", + }, + "type": "required", + }, + }, + "values": {}, +} +`; + +exports[`vineResolver > should return all the errors from vineResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +{ + "errors": { + "accessToken": { + "message": "Invalid value provided for accessToken field", + "ref": undefined, + "type": "unionOfTypes", + "types": { + "unionOfTypes": "Invalid value provided for accessToken field", + }, + }, + "birthYear": { + "message": "The birthYear field must be a number", + "ref": undefined, + "type": "number", + "types": { + "number": "The birthYear field must be a number", + }, + }, + "dateStr": { + "message": "The dateStr field must be defined", + "ref": undefined, + "type": "required", + "types": { + "required": "The dateStr field must be defined", + }, + }, + "email": { + "message": "The email field must be a valid email address", + "ref": { + "name": "email", + }, + "type": "email", + "types": { + "email": "The email field must be a valid email address", + }, + }, + "enabled": { + "message": "The enabled field must be defined", + "ref": undefined, + "type": "required", + "types": { + "required": "The enabled field must be defined", + }, + }, + "like": [ + { + "id": { + "message": "The id field must be a number", + "ref": undefined, + "type": "number", + "types": { + "number": "The id field must be a number", + }, + }, + "name": { + "message": "The name field must be defined", + "ref": undefined, + "type": "required", + "types": { + "required": "The name field must be defined", + }, + }, + }, + ], + "password": { + "message": "The password field format is invalid", + "ref": { + "name": "password", + }, + "type": "regex", + "types": { + "regex": "The password field format is invalid", + }, + }, + "repeatPassword": { + "message": "The repeatPassword field must be defined", + "ref": undefined, + "type": "required", + "types": { + "required": "The repeatPassword field must be defined", + }, + }, + "tags": { + "message": "The tags field must be defined", + "ref": undefined, + "type": "required", + "types": { + "required": "The tags field must be defined", + }, + }, + "username": { + "message": "The username field must be defined", + "ref": { + "name": "username", + }, + "type": "required", + "types": { + "required": "The username field must be defined", + }, + }, + }, + "values": {}, +} +`; diff --git a/vine/src/__tests__/vine.ts b/vine/src/__tests__/vine.ts new file mode 100644 index 00000000..a95f7dd3 --- /dev/null +++ b/vine/src/__tests__/vine.ts @@ -0,0 +1,53 @@ +import { vineResolver } from '..'; +import { schema, validData, fields, invalidData } from './__fixtures__/data'; + +const shouldUseNativeValidation = false; + +describe('vineResolver', () => { + it('should return values from vineResolver when validation pass', async () => { + const schemaSpy = vi.spyOn(schema, 'validate'); + + const result = await vineResolver(schema)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(schemaSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from vineResolver when validation fails', async () => { + const result = await vineResolver(schema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from vineResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await vineResolver(schema)(invalidData, undefined, { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return values from vineResolver when validation pass & raw=true', async () => { + const schemaSpy = vi.spyOn(schema, 'validate'); + + const result = await vineResolver(schema, undefined, { raw: true })( + validData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(schemaSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); +}); diff --git a/vine/src/index.ts b/vine/src/index.ts new file mode 100644 index 00000000..6266c98e --- /dev/null +++ b/vine/src/index.ts @@ -0,0 +1,2 @@ +export * from './vine'; +export * from './types'; diff --git a/vine/src/types.ts b/vine/src/types.ts new file mode 100644 index 00000000..e7d50bdd --- /dev/null +++ b/vine/src/types.ts @@ -0,0 +1,13 @@ +import { FieldValues, ResolverResult, ResolverOptions } from 'react-hook-form'; +import { VineValidator } from '@vinejs/vine'; +import { ValidationOptions } from '@vinejs/vine/build/src/types'; + +export type Resolver = >( + schema: T, + schemaOptions?: ValidationOptions, + resolverOptions?: { raw?: boolean }, +) => ( + values: TFieldValues, + context: TContext | undefined, + options: ResolverOptions, +) => Promise>; diff --git a/vine/src/vine.ts b/vine/src/vine.ts new file mode 100644 index 00000000..28f9afa9 --- /dev/null +++ b/vine/src/vine.ts @@ -0,0 +1,66 @@ +import { appendErrors, FieldError, FieldErrors } from 'react-hook-form'; +import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import { SimpleErrorReporter, errors } from '@vinejs/vine'; +import type { Resolver } from './types'; + +const parseErrorSchema = ( + vineErrors: SimpleErrorReporter['errors'], + validateAllFieldCriteria: boolean, +) => { + const schemaErrors: Record = {}; + + for (const error of vineErrors) { + const { field, rule, message } = error; + const path = field; + + if (!(path in schemaErrors)) { + schemaErrors[path] = { message, type: rule }; + } + + if (validateAllFieldCriteria) { + const { types } = schemaErrors[path]; + const messages = types && types[rule]; + + schemaErrors[path] = appendErrors( + path, + validateAllFieldCriteria, + schemaErrors, + rule, + messages ? [...(messages as string[]), message] : message, + ) as FieldError; + } + } + + return schemaErrors; +}; + +export const vineResolver: Resolver = + (schema, schemaOptions, resolverOptions = {}) => + async (values, _, options) => { + try { + const data = await schema.validate(values, schemaOptions); + + options.shouldUseNativeValidation && validateFieldsNatively({}, options); + + return { + errors: {} as FieldErrors, + values: resolverOptions.raw ? values : data, + }; + } catch (error: any) { + if (error instanceof errors.E_VALIDATION_ERROR) { + return { + values: {}, + errors: toNestErrors( + parseErrorSchema( + error.messages, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, + ), + }; + } + + throw error; + } + };