From 2fc978e59d5a475b8cad18de357bcfcf3b708b3a Mon Sep 17 00:00:00 2001 From: jr Date: Sat, 22 Mar 2025 13:27:49 +0200 Subject: [PATCH] feat: add valita --- README.md | 33 ++++ bun.lock | 3 + config/node-13-exports.js | 1 + package.json | 16 +- valita/package.json | 18 ++ .../src/__tests__/Form-native-validation.tsx | 100 +++++++++++ valita/src/__tests__/Form.tsx | 109 ++++++++++++ valita/src/__tests__/__fixtures__/data.ts | 144 ++++++++++++++++ .../__tests__/__snapshots__/valita.ts.snap | 157 ++++++++++++++++++ valita/src/__tests__/valita.ts | 82 +++++++++ valita/src/index.ts | 1 + valita/src/valita.ts | 126 ++++++++++++++ 12 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 valita/package.json create mode 100644 valita/src/__tests__/Form-native-validation.tsx create mode 100644 valita/src/__tests__/Form.tsx create mode 100644 valita/src/__tests__/__fixtures__/data.ts create mode 100644 valita/src/__tests__/__snapshots__/valita.ts.snap create mode 100644 valita/src/__tests__/valita.ts create mode 100644 valita/src/index.ts create mode 100644 valita/src/valita.ts diff --git a/README.md b/README.md index 5c091835..2bc039d7 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Install your preferred validation library alongside `@hookform/resolvers`. | vine | ✅ | `firstError | all` | | yup | ✅ | `firstError | all` | | zod | ✅ | `firstError | all` | +| valita | ❌ | `firstError` | ## TypeScript @@ -117,6 +118,7 @@ useForm, any, z.output>({ - [VineJS](#vinejs) - [fluentvalidation-ts](#fluentvalidation-ts) - [standard-schema](#standard-schema) + - [valita](#valita) - [Backers](#backers) - [Sponsors](#sponsors) - [Contributors](#contributors) @@ -909,6 +911,37 @@ const App = () => { }; ``` +### [valita](https://github.com/badrap/valita) + +A typesafe validation & parsing library for TypeScript. + +[![npm](https://img.shields.io/bundlephobia/minzip/@badrap/valita?style=for-the-badge)](https://bundlephobia.com/result?p=@badrap/valita) + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { valitaResolver } from '@hookform/resolvers/valita'; +import * as v from '@badrap/valita'; + +const schema = v.object({ + username: v.string(), + password: v.string(), +}); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: valitaResolver(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.lock b/bun.lock index de4a95df..264b07e0 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ }, "devDependencies": { "@sinclair/typebox": "^0.34.30", + "@badrap/valita": "^0.4.3", "@standard-schema/spec": "^1.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -274,6 +275,8 @@ "@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], + "@badrap/valita": ["@badrap/valita@0.4.3", "", {}, "sha512-C9iZSrVlTb610dxZ2oatK5LwefaHv0Q9eYfVDH3co846x7WinhCfc8jCDTE55yM8WxlmOfX2ckKmsSr7KzZ/gg=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="], "@csstools/css-calc": ["@csstools/css-calc@2.1.2", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3" } }, "sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw=="], diff --git a/config/node-13-exports.js b/config/node-13-exports.js index efab7b13..5e453206 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -21,6 +21,7 @@ const subRepositories = [ 'vine', 'fluentvalidation-ts', 'standard-schema', + 'valita', ]; const copySrc = () => { diff --git a/package.json b/package.json index b523290e..b6fcc1ec 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,12 @@ "import": "./standard-schema/dist/standard-schema.mjs", "require": "./standard-schema/dist/standard-schema.js" }, + "./valita": { + "types": "./valita/dist/index.d.ts", + "umd": "./valita/dist/valita.umd.js", + "import": "./valita/dist/valita.mjs", + "require": "./valita/dist/valita.js" + }, "./package.json": "./package.json", "./*": "./*" }, @@ -193,7 +199,10 @@ "fluentvalidation-ts/dist", "standard-schema/package.json", "standard-schema/src", - "standard-schema/dist" + "standard-schema/dist", + "valita/package.json", + "valita/src", + "valita/dist" ], "publishConfig": { "access": "public" @@ -221,6 +230,7 @@ "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", + "build:valita": "microbundle --cwd valita --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@badrap/valita=valita", "postbuild": "node ./config/node-13-exports.js && check-export-map", "lint": "bunx @biomejs/biome check --write --vcs-use-ignore-file=true .", "lint:types": "tsc", @@ -254,7 +264,8 @@ "typeschema", "vine", "fluentvalidation-ts", - "standard-schema" + "standard-schema", + "valita" ], "repository": { "type": "git", @@ -268,6 +279,7 @@ "homepage": "https://react-hook-form.com", "devDependencies": { "@sinclair/typebox": "^0.34.30", + "@badrap/valita": "^0.4.3", "@standard-schema/spec": "^1.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/valita/package.json b/valita/package.json new file mode 100644 index 00000000..0f48ee04 --- /dev/null +++ b/valita/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hookform/resolvers/valita", + "amdName": "hookformResolversValita", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: valita", + "main": "dist/valita.js", + "module": "dist/valita.module.js", + "umd:main": "dist/valita.umd.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", + "license": "MIT", + "peerDependencies": { + "@badrap/valita": "0.4.x", + "@hookform/resolvers": "^2.0.0", + "react-hook-form": "^7.0.0" + } +} diff --git a/valita/src/__tests__/Form-native-validation.tsx b/valita/src/__tests__/Form-native-validation.tsx new file mode 100644 index 00000000..b85eb181 --- /dev/null +++ b/valita/src/__tests__/Form-native-validation.tsx @@ -0,0 +1,100 @@ +import * as v from '@badrap/valita'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { valitaResolver } from '..'; + +const USERNAME_REQUIRED_MESSAGE = 'username field is required'; +const PASSWORD_REQUIRED_MESSAGE = 'password field is required'; +const USERNAME_LENGTH_TOO_SHORT = 'username is too short'; + +function strRequired(message: string) { + return (value: string) => { + if (value === '') { + return v.err(message); + } + return v.ok(value); + }; +} + +function strMinLength(min: number) { + return (value: string) => { + if (value.length < min) { + return v.err(USERNAME_LENGTH_TOO_SHORT); + } + return v.ok(value); + }; +} + +const schema = v.object({ + username: v + .string() + .chain(strRequired(USERNAME_REQUIRED_MESSAGE)) + .chain(strMinLength(2)), + password: v + .string() + .chain(strRequired(PASSWORD_REQUIRED_MESSAGE)) + .chain(strMinLength(2)), +}); + +type FormData = { username: string; password: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: valitaResolver(schema), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with valita", 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_REQUIRED_MESSAGE); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(false); + expect(passwordField.validationMessage).toBe(PASSWORD_REQUIRED_MESSAGE); + + await user.type(screen.getByPlaceholderText(/password/i), 'password'); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(true); + expect(passwordField.validationMessage).toBe(''); +}); diff --git a/valita/src/__tests__/Form.tsx b/valita/src/__tests__/Form.tsx new file mode 100644 index 00000000..c63dcdd0 --- /dev/null +++ b/valita/src/__tests__/Form.tsx @@ -0,0 +1,109 @@ +import * as v from '@badrap/valita'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { valitaResolver } from '..'; + +const USERNAME_REQUIRED_MESSAGE = 'username field is required'; +const PASSWORD_REQUIRED_MESSAGE = 'password field is required'; +const USERNAME_LENGTH_TOO_SHORT = 'username is too short'; + +function strRequired(message: string) { + return (value: string) => { + if (value === '') { + return v.err(message); + } + return v.ok(value); + }; +} + +function strMinLength(min: number) { + return (value: string) => { + if (value.length < min) { + return v.err(USERNAME_LENGTH_TOO_SHORT); + } + return v.ok(value); + }; +} + +const schema = v.object({ + username: v + .string() + .chain(strRequired(USERNAME_REQUIRED_MESSAGE)) + .chain(strMinLength(2)), + password: v + .string() + .chain(strRequired(PASSWORD_REQUIRED_MESSAGE)) + .chain(strMinLength(2)), +}); + +type FormData = { username: string; password: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: valitaResolver(schema), + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +describe('valita form validation errors', () => { + test('ensure custom validation messages are shown', async () => { + const handleSubmit = vi.fn(); + render(); + + expect(screen.queryAllByRole('alert')).toHaveLength(0); + + await user.type(screen.getByPlaceholderText('username'), 'a'); + await user.click(screen.getByText(/submit/i)); + + expect(screen.getByText(/username is too short/i)).toBeInTheDocument(); + expect(screen.getByText(/password field is required/i)).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); + }); +}); + +export function TestComponentManualType({ + onSubmit, +}: { + onSubmit: (data: FormData) => void; +}) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm, undefined, FormData>({ + resolver: valitaResolver(schema), + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} diff --git a/valita/src/__tests__/__fixtures__/data.ts b/valita/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..4962511e --- /dev/null +++ b/valita/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,144 @@ +import * as v from '@badrap/valita'; +import { Field, InternalFieldName } from 'react-hook-form'; + +function strMinMaxLen(min: number, max: number) { + return (value: string) => { + if (value.length < min) { + return v.err(`Must be at least ${min} characters in length`); + } + if (value.length > max) { + return v.err(`Must be at most ${max} characters in length`); + } + return v.ok(value); + }; +} + +function strMinLen(min: number) { + return (value: string) => { + if (value.length < min) { + return v.err(`Must be at least ${min} characters in length`); + } + return v.ok(value); + }; +} + +function strRegex(regex: RegExp, message: string) { + return (value: string) => { + if (!regex.test(value)) { + return v.err(message); + } + return v.ok(value); + }; +} + +function strPassword() { + return v + .string() + .chain(strRegex(new RegExp('.*[A-Z].*'), 'One uppercase character')) + .chain(strRegex(new RegExp('.*[a-z].*'), 'One lowercase character')) + .chain(strRegex(new RegExp('.*\\d.*'), 'One number')) + .chain( + strRegex( + new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), + 'One special character', + ), + ) + .chain(strMinLen(8)); +} + +function numberMin(min: number) { + return (value: number) => { + if (value < min) { + return v.err(`Must be at least ${min}`); + } + return v.ok(value); + }; +} + +function numberMax(max: number) { + return (value: number) => { + if (value > max) { + return v.err(`Must be at most ${max}`); + } + return v.ok(value); + }; +} + +function strEmail() { + return (value: string) => { + if (!value.includes('@')) { + return v.err('Invalid email address'); + } + return v.ok(value); + }; +} + +export const schema = v.object({ + username: v.string().chain(strMinMaxLen(2, 20)), + password: strPassword(), + repeatPassword: strPassword(), + accessToken: v.union(v.string(), v.number()), + birthYear: v.number().chain(numberMin(1900)).chain(numberMax(2021)), + email: v.string().chain(strEmail()), + tags: v.array(v.string()), + enabled: v.boolean(), + like: v.object({ + id: v.number(), + name: v.string().chain(strMinLen(4)), + }), +}); + +export const schemaError = v.union( + v.object({ type: v.literal('a') }), + v.object({ type: v.literal('b') }), +); + +export const validSchemaErrorData = { type: 'a' } as v.Infer< + typeof schemaError +>; + +export const invalidSchemaErrorData = { type: 'c' } as any as v.Infer< + typeof schemaError +>; + +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], +} as any as v.Infer; + +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/valita/src/__tests__/__snapshots__/valita.ts.snap b/valita/src/__tests__/__snapshots__/valita.ts.snap new file mode 100644 index 00000000..7f015bbc --- /dev/null +++ b/valita/src/__tests__/__snapshots__/valita.ts.snap @@ -0,0 +1,157 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`valitaResolver > should return a single error from valitaResolver when validation fails 1`] = ` +{ + "errors": { + "accessToken": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "birthYear": { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + "email": { + "message": "Invalid email address", + "ref": { + "name": "email", + }, + "type": "custom_error", + }, + "enabled": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "like": { + "id": { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + "name": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "custom_error", + }, + "repeatPassword": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "tags": [ + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + ], + "username": { + "message": "missing_value", + "ref": { + "name": "username", + }, + "type": "missing_value", + }, + }, + "values": {}, +} +`; + +exports[`valitaResolver > should return all the errors from valitaResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +{ + "errors": { + "accessToken": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "birthYear": { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + "email": { + "message": "Invalid email address", + "ref": { + "name": "email", + }, + "type": "custom_error", + }, + "enabled": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "like": { + "id": { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + "name": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "custom_error", + }, + "repeatPassword": { + "message": "missing_value", + "ref": undefined, + "type": "missing_value", + }, + "tags": [ + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + { + "message": "invalid_type", + "ref": undefined, + "type": "invalid_type", + }, + ], + "username": { + "message": "missing_value", + "ref": { + "name": "username", + }, + "type": "missing_value", + }, + }, + "values": {}, +} +`; diff --git a/valita/src/__tests__/valita.ts b/valita/src/__tests__/valita.ts new file mode 100644 index 00000000..83c48161 --- /dev/null +++ b/valita/src/__tests__/valita.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */ +import { valitaResolver } from '..'; +import { + fields, + invalidData, + invalidSchemaErrorData, + schema, + schemaError, + validData, + validSchemaErrorData, +} from './__fixtures__/data'; + +const shouldUseNativeValidation = false; +describe('valitaResolver', () => { + it('should return values from valitaResolver when validation pass', async () => { + const result = await valitaResolver(schema)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from valitaResolver when validation fails', async () => { + const result = await valitaResolver(schema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from valitaResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await valitaResolver(schema)(invalidData, undefined, { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should be able to validate variants without errors', async () => { + const result = await valitaResolver(schemaError)( + validSchemaErrorData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(result).toEqual({ + errors: {}, + values: { + type: 'a', + }, + }); + }); + + it('should be able to validate invalid schema with errors', async () => { + const result = await valitaResolver(schemaError)( + invalidSchemaErrorData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(result).toEqual({ + errors: { + type: { + message: 'invalid_literal', + ref: undefined, + type: 'invalid_literal', + }, + }, + values: {}, + }); + }); +}); diff --git a/valita/src/index.ts b/valita/src/index.ts new file mode 100644 index 00000000..b58e6ab9 --- /dev/null +++ b/valita/src/index.ts @@ -0,0 +1 @@ +export * from './valita'; diff --git a/valita/src/valita.ts b/valita/src/valita.ts new file mode 100644 index 00000000..e03d3f5c --- /dev/null +++ b/valita/src/valita.ts @@ -0,0 +1,126 @@ +import * as v from '@badrap/valita'; +import { toNestErrors } from '@hookform/resolvers'; +import { FieldError, FieldValues, Resolver } from 'react-hook-form'; + +/** + * Creates a resolver for react-hook-form using valita + * + * @param {v.Type} schema - The valita schema to validate against + * @param {Object} [schemaOptions] - Optional valita validation options + * @param {v.ParseOptions['mode']} [schemaOptions.parsingMode="strict"] - Optional valita parsing mode + * @param {Object} [resolverOptions] - Optional resolver-specific configuration + * @param {boolean} [resolverOptions.raw=false] - If true, returns the raw form values instead of the parsed data + * + * @returns {Resolver>} A resolver function compatible with react-hook-form + * + * @example + * const schema = valita.object({ + * name: valita.string(), + * age: valita.number() + * }); + * + * useForm({ + * resolver: valitaResolver(schema) + * }); + */ +export function valitaResolver>( + schema: Schema, + schemaOptions: { + /** + * Parsing mode for valita + * @default strict + */ + parsingMode?: v.ParseOptions['mode']; + } = { + parsingMode: 'strict', + }, + resolverOptions: { + /** + * Return the raw input values rather than the parsed values. + * @default false + */ + raw?: boolean; + } = {}, +): Resolver> { + return async (values, _, options) => { + const result = schema.try(values, { + mode: schemaOptions.parsingMode, + }); + + if (!result.ok) { + return { + values: {}, + errors: toNestErrors(parseIssues(result.issues), options), + }; + } + + return { + values: resolverOptions.raw + ? Object.assign({}, values) + : (result.value as FieldValues), + errors: {}, + }; + }; +} + +type ValitaIssue = v.ValitaError['issues'][number]; + +function getMessageFromIssue(issue: ValitaIssue): string { + if (issue.code === 'custom_error' && issue.error !== undefined) { + if (typeof issue.error === 'string') { + return issue.error; + } + if (issue.error.message !== undefined) { + return issue.error.message; + } + } + return issue.code; +} + +function parseIssues(issues: readonly ValitaIssue[]) { + const errors: Record = {}; + + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + const path = getDotPath(issue); + + if (!path) { + continue; + } + + if (!errors[path]) { + errors[path] = { message: getMessageFromIssue(issue), type: issue.code }; + } + } + + return errors; +} + +/** + * Creates and returns the dot path of an issue if possible. + * + * Adapted from @standard-schema/util getDotPath() + * + * @param issue The issue to get the dot path from. + * + * @returns The dot path or null. + */ +function getDotPath(issue: ValitaIssue): string | null { + if (issue.path?.length) { + let dotPath = ''; + + for (const key of issue.path) { + if (typeof key === 'string' || typeof key === 'number') { + if (dotPath) { + dotPath += `.${key}`; + } else { + dotPath += key; + } + } else { + return null; + } + } + return dotPath; + } + return null; +}