diff --git a/README.md b/README.md index c169c3bd..07a0362f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ - [TypeSchema](#typeschema) - [effect-ts](#effect-ts) - [VineJS](#vinejs) + - [fluentvalidation-ts](#fluentvalidation-ts) - [Backers](#backers) - [Sponsors](#sponsors) - [Contributors](#contributors) @@ -734,6 +735,48 @@ const App = () => { }; ``` + +### [fluentvalidation-ts](https://github.com/AlexJPotter/fluentvalidation-ts) + +A TypeScript-first library for building strongly-typed validation rules + +[![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 { fluentValidationResolver } from '@hookform/resolvers/fluentvalidation-ts'; +import { Validator } from 'fluentvalidation-ts'; + +class FormDataValidator extends Validator { + constructor() { + super(); + + this.ruleFor('username') + .notEmpty() + .withMessage('username is a required field'); + this.ruleFor('password') + .notEmpty() + .withMessage('password is a required field'); + } +} + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: fluentValidationResolver(new FormDataValidator()), + }); + + 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/bun.lockb b/bun.lockb index b6759b67..2e09884e 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 5c74cf0c..c60fd718 100644 --- a/config/node-13-exports.js +++ b/config/node-13-exports.js @@ -19,6 +19,7 @@ const subRepositories = [ 'typeschema', 'effect-ts', 'vine', + 'fluentvalidation-ts', ]; const copySrc = () => { diff --git a/fluentvalidation-ts/package.json b/fluentvalidation-ts/package.json new file mode 100644 index 00000000..d6f56ff1 --- /dev/null +++ b/fluentvalidation-ts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hookform/resolvers/fluentvalidation-ts", + "amdName": "hookformResolversfluentvalidation-ts", + "version": "1.0.0", + "private": true, + "description": "React Hook Form validation resolver: fluentvalidation-ts", + "main": "dist/fluentvalidation-ts.js", + "module": "dist/fluentvalidation-ts.module.js", + "umd:main": "dist/fluentvalidation-ts.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", + "fluentvalidation-ts": "^3.0.0" + } +} diff --git a/fluentvalidation-ts/src/__tests__/Form-native-validation.tsx b/fluentvalidation-ts/src/__tests__/Form-native-validation.tsx new file mode 100644 index 00000000..5b9ed768 --- /dev/null +++ b/fluentvalidation-ts/src/__tests__/Form-native-validation.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { Validator } from 'fluentvalidation-ts'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { fluentValidationResolver } from '../fluentvalidation-ts'; + +const USERNAME_REQUIRED_MESSAGE = 'username field is required'; +const PASSWORD_REQUIRED_MESSAGE = 'password field is required'; + +type FormData = { + username: string; + password: string; +}; + +class FormDataValidator extends Validator { + constructor() { + super(); + + this.ruleFor('username').notEmpty().withMessage(USERNAME_REQUIRED_MESSAGE); + this.ruleFor('password').notEmpty().withMessage(PASSWORD_REQUIRED_MESSAGE); + } +} + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: fluentValidationResolver(new FormDataValidator()), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with fluentvalidation-ts", 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(/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/fluentvalidation-ts/src/__tests__/Form.tsx b/fluentvalidation-ts/src/__tests__/Form.tsx new file mode 100644 index 00000000..48dbf356 --- /dev/null +++ b/fluentvalidation-ts/src/__tests__/Form.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { Validator } from 'fluentvalidation-ts'; +import React from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { fluentValidationResolver } from '../fluentvalidation-ts'; + +type FormData = { + username: string; + password: string; +}; + +class FormDataValidator extends Validator { + constructor() { + super(); + + this.ruleFor('username') + .notEmpty() + .withMessage('username is a required field'); + this.ruleFor('password') + .notEmpty() + .withMessage('password is a required field'); + } +} + +interface Props { + onSubmit: SubmitHandler; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + formState: { errors }, + handleSubmit, + } = useForm({ + resolver: fluentValidationResolver(new FormDataValidator()), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Yup 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 is a required field/i)).toBeInTheDocument(); + expect(screen.getByText(/password is a required field/i)).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/fluentvalidation-ts/src/__tests__/__fixtures__/data.ts b/fluentvalidation-ts/src/__tests__/__fixtures__/data.ts new file mode 100644 index 00000000..03953e23 --- /dev/null +++ b/fluentvalidation-ts/src/__tests__/__fixtures__/data.ts @@ -0,0 +1,121 @@ +import { Validator } from 'fluentvalidation-ts'; +import { Field, InternalFieldName } from 'react-hook-form'; + +const beNumeric = (value: string | number | undefined) => !isNaN(Number(value)); + +export type Schema = { + username: string; + password: string; + repeatPassword: string; + accessToken?: string; + birthYear?: number; + email?: string; + tags?: string[]; + enabled?: boolean; + like?: { + id: number; + name: string; + }[]; +}; + +export type SchemaWithWhen = { + name: string; + value: string; +}; + +export class SchemaValidator extends Validator { + constructor() { + super(); + + this.ruleFor('username') + .notEmpty() + .matches(/^\w+$/) + .minLength(3) + .maxLength(30); + + this.ruleFor('password') + .notEmpty() + .matches(/.*[A-Z].*/) + .withMessage('One uppercase character') + .matches(/.*[a-z].*/) + .withMessage('One lowercase character') + .matches(/.*\d.*/) + .withMessage('One number') + .matches(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*')) + .withMessage('One special character') + .minLength(8) + .withMessage('Must be at least 8 characters in length'); + + this.ruleFor('repeatPassword') + .notEmpty() + .must((repeatPassword, data) => repeatPassword === data.password); + + this.ruleFor('accessToken'); + this.ruleFor('birthYear') + .must(beNumeric) + .inclusiveBetween(1900, 2013) + .when((birthYear) => birthYear != undefined); + + this.ruleFor('email').emailAddress(); + this.ruleFor('tags'); + this.ruleFor('enabled'); + + this.ruleForEach('like').setValidator(() => new LikeValidator()); + } +} + +export class LikeValidator extends Validator<{ + id: number; + name: string; +}> { + constructor() { + super(); + + this.ruleFor('id').notNull(); + this.ruleFor('name').notEmpty().length(4, 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', + }, + ], +} as Schema; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: [{ id: 'z' }], + // Must be set to "unknown", otherwise typescript knows that it is invalid +} as unknown as Required; + +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/fluentvalidation-ts/src/__tests__/__snapshots__/fluentvalidation-ts.ts.snap b/fluentvalidation-ts/src/__tests__/__snapshots__/fluentvalidation-ts.ts.snap new file mode 100644 index 00000000..f2ef0e42 --- /dev/null +++ b/fluentvalidation-ts/src/__tests__/__snapshots__/fluentvalidation-ts.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`fluentValidationResolver > should return a single error from fluentValidationResolver when validation fails 1`] = ` +{ + "errors": { + "birthYear": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + "email": { + "message": "Not a valid email address", + "ref": { + "name": "email", + }, + "type": "validation", + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "validation", + }, + "repeatPassword": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + }, + "values": {}, +} +`; + +exports[`fluentValidationResolver > should return a single error from fluentValidationResolver with \`mode: sync\` when validation fails 1`] = ` +{ + "errors": { + "birthYear": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + "email": { + "message": "Not a valid email address", + "ref": { + "name": "email", + }, + "type": "validation", + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "validation", + }, + "repeatPassword": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + }, + "values": {}, +} +`; + +exports[`fluentValidationResolver > should return all the errors from fluentValidationResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +{ + "errors": { + "birthYear": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + "email": { + "message": "Not a valid email address", + "ref": { + "name": "email", + }, + "type": "validation", + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "validation", + }, + "repeatPassword": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + }, + "values": {}, +} +`; + +exports[`fluentValidationResolver > should return all the errors from fluentValidationResolver when validation fails with \`validateAllFieldCriteria\` set to true and \`mode: sync\` 1`] = ` +{ + "errors": { + "birthYear": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + "email": { + "message": "Not a valid email address", + "ref": { + "name": "email", + }, + "type": "validation", + }, + "password": { + "message": "One uppercase character", + "ref": { + "name": "password", + }, + "type": "validation", + }, + "repeatPassword": { + "message": "Value is not valid", + "ref": undefined, + "type": "validation", + }, + }, + "values": {}, +} +`; diff --git a/fluentvalidation-ts/src/__tests__/fluentvalidation-ts.ts b/fluentvalidation-ts/src/__tests__/fluentvalidation-ts.ts new file mode 100644 index 00000000..52f611d1 --- /dev/null +++ b/fluentvalidation-ts/src/__tests__/fluentvalidation-ts.ts @@ -0,0 +1,113 @@ +/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */ +import { fluentValidationResolver } from '..'; +import { + SchemaValidator, + fields, + invalidData, + validData, +} from './__fixtures__/data'; + +const shouldUseNativeValidation = false; + +const validator = new SchemaValidator(); + +describe('fluentValidationResolver', () => { + it('should return values from fluentValidationResolver when validation pass', async () => { + const validatorSpy = vi.spyOn(validator, 'validate'); + + const result = await fluentValidationResolver(validator)( + validData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(validatorSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return values from fluentValidationResolver with `mode: sync` when validation pass', async () => { + const validatorSpy = vi.spyOn(validator, 'validate'); + + const result = await fluentValidationResolver(validator)( + validData, + undefined, + { fields, shouldUseNativeValidation }, + ); + + expect(validatorSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return a single error from fluentValidationResolver when validation fails', async () => { + const result = await fluentValidationResolver(validator)( + invalidData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); + + it('should return a single error from fluentValidationResolver with `mode: sync` when validation fails', async () => { + const validateSpy = vi.spyOn(validator, 'validate'); + + const result = await fluentValidationResolver(validator)( + invalidData, + undefined, + { fields, shouldUseNativeValidation }, + ); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from fluentValidationResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await fluentValidationResolver(validator)( + invalidData, + undefined, + { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from fluentValidationResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => { + const result = await fluentValidationResolver(validator)( + invalidData, + undefined, + { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); + + it('should return values from fluentValidationResolver when validation pass & raw=true', async () => { + const schemaSpy = vi.spyOn(validator, 'validate'); + + const result = await fluentValidationResolver(validator)( + validData, + undefined, + { + fields, + shouldUseNativeValidation, + }, + ); + + expect(schemaSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); +}); diff --git a/fluentvalidation-ts/src/fluentvalidation-ts.ts b/fluentvalidation-ts/src/fluentvalidation-ts.ts new file mode 100644 index 00000000..f0b794f2 --- /dev/null +++ b/fluentvalidation-ts/src/fluentvalidation-ts.ts @@ -0,0 +1,102 @@ +import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import { + AsyncValidator, + ValidationErrors, + Validator, +} from 'fluentvalidation-ts'; +import { FieldError, FieldValues, Resolver } from 'react-hook-form'; + +function traverseObject( + object: ValidationErrors, + errors: Record, + parentIndices: (string | number)[] = [], +) { + for (const key in object) { + const currentIndex = [...parentIndices, key]; + const currentValue = object[key]; + + if (Array.isArray(currentValue)) { + currentValue.forEach((item: any, index: number) => { + traverseObject(item, errors, [...currentIndex, index]); + }); + } else if (typeof currentValue === 'object' && currentValue !== null) { + traverseObject(currentValue, errors, currentIndex); + } else if (typeof currentValue === 'string') { + errors[currentIndex.join('.')] = { + type: 'validation', + message: currentValue, + }; + } + } +} + +const parseErrorSchema = ( + validationErrors: ValidationErrors, + validateAllFieldCriteria: boolean, +) => { + if (validateAllFieldCriteria) { + // TODO: check this but i think its always one validation error + } + + const errors: Record = {}; + traverseObject(validationErrors, errors); + + return errors; +}; + +export function fluentValidationResolver( + validator: Validator, +): Resolver { + return async (values, _context, options) => { + const validationResult = validator.validate(values); + const isValid = Object.keys(validationResult).length === 0; + + options.shouldUseNativeValidation && validateFieldsNatively({}, options); + + return isValid + ? { + values: values, + errors: {}, + } + : { + values: {}, + errors: toNestErrors( + parseErrorSchema( + validationResult, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, + ), + }; + }; +} + +export function fluentAsyncValidationResolver< + TFieldValues extends FieldValues, + TValidator extends AsyncValidator, +>(validator: TValidator): Resolver { + return async (values, _context, options) => { + const validationResult = await validator.validateAsync(values); + const isValid = Object.keys(validationResult).length === 0; + + options.shouldUseNativeValidation && validateFieldsNatively({}, options); + + return isValid + ? { + values: values, + errors: {}, + } + : { + values: {}, + errors: toNestErrors( + parseErrorSchema( + validationResult, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, + ), + }; + }; +} diff --git a/fluentvalidation-ts/src/index.ts b/fluentvalidation-ts/src/index.ts new file mode 100644 index 00000000..5f27a0c3 --- /dev/null +++ b/fluentvalidation-ts/src/index.ts @@ -0,0 +1 @@ +export * from './fluentvalidation-ts'; diff --git a/package.json b/package.json index f9ab7b96..9189bbe3 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,12 @@ "import": "./vine/dist/vine.mjs", "require": "./vine/dist/vine.js" }, + "./fluentvalidation-ts": { + "types": "./fluentvalidation-ts/dist/index.d.ts", + "umd": "./fluentvalidation-ts/dist/fluentvalidation-ts.umd.js", + "import": "./fluentvalidation-ts/dist/fluentvalidation-ts.mjs", + "require": "./fluentvalidation-ts/dist/fluentvalidation-ts.js" + }, "./package.json": "./package.json", "./*": "./*" }, @@ -175,13 +181,16 @@ "effect-ts/dist", "vine/package.json", "vine/src", - "vine/dist" + "vine/dist", + "fluentvalidation-ts/package.json", + "fluentvalidation-ts/src", + "fluentvalidation-ts/dist" ], "publishConfig": { "access": "public" }, "scripts": { - "prepare": "run-s build:src build && check-export-map", + "prepare": "run-s build:src", "build": "cross-env npm-run-all --parallel 'build:*'", "build:src": "microbundle build --globals react-hook-form=ReactHookForm", "build:zod": "microbundle --cwd zod --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", @@ -201,7 +210,8 @@ "build:typeschema": "microbundle --cwd typeschema --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@typeschema/main=main", "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,@effect/schema/ParseResult=EffectSchemaArrayFormatter,effect/Effect=Effect", "build:vine": "microbundle --cwd vine --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@vinejs/vine=vine", - "postbuild": "node ./config/node-13-exports.js", + "build:fluentvalidation-ts": "microbundle --cwd fluentvalidation-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm", + "postbuild": "node ./config/node-13-exports.js && check-export-map", "lint": "bunx @biomejs/biome check --write --vcs-use-ignore-file=true .", "lint:types": "tsc", "test": "vitest run", @@ -232,7 +242,8 @@ "TypeBox", "arktype", "typeschema", - "vine" + "vine", + "fluentvalidation-ts" ], "repository": { "type": "git", @@ -245,9 +256,9 @@ }, "homepage": "https://react-hook-form.com", "devDependencies": { - "@effect/schema": "^0.68.15", + "@effect/schema": "^0.68.17", "@sinclair/typebox": "^0.32.34", - "@testing-library/dom": "^10.3.0", + "@testing-library/dom": "^10.3.1", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", @@ -266,7 +277,7 @@ "class-validator": "^0.14.1", "computed-types": "^1.11.2", "cross-env": "^7.0.3", - "effect": "^3.4.6", + "effect": "^3.4.7", "fp-ts": "^2.16.7", "io-ts": "^2.2.21", "io-ts-types": "^0.5.19", @@ -291,7 +302,8 @@ "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.6.0", "yup": "^1.4.0", - "zod": "^3.23.8" + "zod": "^3.23.8", + "fluentvalidation-ts": "^3.2.0" }, "peerDependencies": { "react-hook-form": "^7.0.0" diff --git a/src/toNestErrors.ts b/src/toNestErrors.ts index 152e64e0..0aa71a8b 100644 --- a/src/toNestErrors.ts +++ b/src/toNestErrors.ts @@ -4,8 +4,8 @@ import { FieldValues, InternalFieldName, ResolverOptions, - set, get, + set, } from 'react-hook-form'; import { validateFieldsNatively } from './validateFieldsNatively';