v1.0 with SW PWA enabled

This commit is contained in:
Blomios
2026-01-01 17:40:53 +01:00
parent 1c0e22aac1
commit 3c8bebb2ad
29775 changed files with 2197201 additions and 119080 deletions

View File

@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { IsNotEmpty } from 'class-validator';
import React from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '..';
class Schema {
@IsNotEmpty()
username: string;
@IsNotEmpty()
password: string;
}
interface Props {
onSubmit: SubmitHandler<Schema>;
}
function TestComponent({ onSubmit }: Props) {
const { register, handleSubmit } = useForm<Schema>({
resolver: classValidatorResolver(Schema),
shouldUseNativeValidation: true,
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} placeholder="username" />
<input {...register('password')} placeholder="password" />
<button type="submit">submit</button>
</form>
);
}
test("form's native validation with Class Validator", async () => {
const handleSubmit = vi.fn();
render(<TestComponent onSubmit={handleSubmit} />);
// 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 should not be empty');
// password
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
expect(passwordField.validity.valid).toBe(false);
expect(passwordField.validationMessage).toBe('password should not be empty');
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('');
});

View File

@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { IsNotEmpty } from 'class-validator';
import React from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '..';
class Schema {
@IsNotEmpty()
username: string;
@IsNotEmpty()
password: string;
}
interface Props {
onSubmit: SubmitHandler<Schema>;
}
function TestComponent({ onSubmit }: Props) {
const {
register,
formState: { errors },
handleSubmit,
} = useForm<Schema>({
resolver: classValidatorResolver(Schema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span role="alert">{errors.username.message}</span>}
<input {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<button type="submit">submit</button>
</form>
);
}
test("form's validation with Class Validator and TypeScript's integration", async () => {
const handleSubmit = vi.fn();
render(<TestComponent onSubmit={handleSubmit} />);
expect(screen.queryAllByRole('alert')).toHaveLength(0);
await user.click(screen.getByText(/submit/i));
expect(screen.getByText(/username should not be empty/i)).toBeInTheDocument();
expect(screen.getByText(/password should not be empty/i)).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,88 @@
import 'reflect-metadata';
import { Type } from 'class-transformer';
import {
IsEmail,
IsNotEmpty,
Length,
Matches,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Field, InternalFieldName } from 'react-hook-form';
class Like {
@IsNotEmpty()
id: number;
@Length(4)
name: string;
}
export class Schema {
@Matches(/^\w+$/)
@Length(3, 30)
username: string;
@Matches(/^[a-zA-Z0-9]{3,30}/)
password: string;
@Min(1900)
@Max(2013)
birthYear: number;
@IsEmail()
email: string;
accessToken: string;
tags: string[];
enabled: boolean;
@ValidateNested({ each: true })
@Type(() => Like)
like: Like[];
}
export const validData: Schema = {
username: 'Doe',
password: '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' }],
};
export const fields: Record<InternalFieldName, Field['_f']> = {
username: {
ref: { name: 'username' },
name: 'username',
},
password: {
ref: { name: 'password' },
name: 'password',
},
email: {
ref: { name: 'email' },
name: 'email',
},
birthday: {
ref: { name: 'birthday' },
name: 'birthday',
},
};

View File

@ -0,0 +1,242 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`classValidatorResolver > should return a single error from classValidatorResolver when validation fails 1`] = `
{
"errors": {
"birthYear": {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
},
"email": {
"message": "email must be an email",
"ref": {
"name": "email",
},
"type": "isEmail",
},
"like": [
{
"name": {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
},
},
],
"password": {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": {
"name": "password",
},
"type": "matches",
},
"username": {
"message": "username must be longer than or equal to 3 characters",
"ref": {
"name": "username",
},
"type": "isLength",
},
},
"values": {},
}
`;
exports[`classValidatorResolver > should return a single error from classValidatorResolver with \`mode: sync\` when validation fails 1`] = `
{
"errors": {
"birthYear": {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
},
"email": {
"message": "email must be an email",
"ref": {
"name": "email",
},
"type": "isEmail",
},
"like": [
{
"name": {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
},
},
],
"password": {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": {
"name": "password",
},
"type": "matches",
},
"username": {
"message": "username must be longer than or equal to 3 characters",
"ref": {
"name": "username",
},
"type": "isLength",
},
},
"values": {},
}
`;
exports[`classValidatorResolver > should return all the errors from classValidatorResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
{
"errors": {
"birthYear": {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
"types": {
"max": "birthYear must not be greater than 2013",
"min": "birthYear must not be less than 1900",
},
},
"email": {
"message": "email must be an email",
"ref": {
"name": "email",
},
"type": "isEmail",
"types": {
"isEmail": "email must be an email",
},
},
"like": [
{
"name": {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
"types": {
"isLength": "name must be longer than or equal to 4 characters",
},
},
},
],
"password": {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": {
"name": "password",
},
"type": "matches",
"types": {
"matches": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
},
},
"username": {
"message": "username must be longer than or equal to 3 characters",
"ref": {
"name": "username",
},
"type": "isLength",
"types": {
"isLength": "username must be longer than or equal to 3 characters",
"matches": "username must match /^\\w+$/ regular expression",
},
},
},
"values": {},
}
`;
exports[`classValidatorResolver > should return all the errors from classValidatorResolver when validation fails with \`validateAllFieldCriteria\` set to true and \`mode: sync\` 1`] = `
{
"errors": {
"birthYear": {
"message": "birthYear must not be greater than 2013",
"ref": undefined,
"type": "max",
"types": {
"max": "birthYear must not be greater than 2013",
"min": "birthYear must not be less than 1900",
},
},
"email": {
"message": "email must be an email",
"ref": {
"name": "email",
},
"type": "isEmail",
"types": {
"isEmail": "email must be an email",
},
},
"like": [
{
"name": {
"message": "name must be longer than or equal to 4 characters",
"ref": undefined,
"type": "isLength",
"types": {
"isLength": "name must be longer than or equal to 4 characters",
},
},
},
],
"password": {
"message": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
"ref": {
"name": "password",
},
"type": "matches",
"types": {
"matches": "password must match /^[a-zA-Z0-9]{3,30}/ regular expression",
},
},
"username": {
"message": "username must be longer than or equal to 3 characters",
"ref": {
"name": "username",
},
"type": "isLength",
"types": {
"isLength": "username must be longer than or equal to 3 characters",
"matches": "username must match /^\\w+$/ regular expression",
},
},
},
"values": {},
}
`;
exports[`validate data with transformer option 1`] = `
{
"errors": {
"random": {
"message": "All fields must be defined.",
"ref": undefined,
"type": "isDefined",
"types": {
"isDefined": "All fields must be defined.",
"isNumber": "Must be a number",
"max": "Cannot be greater than 255",
"min": "Cannot be lower than 0",
},
},
},
"values": {},
}
`;
exports[`validate data with validator option 1`] = `
{
"errors": {
"random": {
"message": "All fields must be defined.",
"ref": undefined,
"type": "isDefined",
"types": {
"isDefined": "All fields must be defined.",
},
},
},
"values": {},
}
`;

View File

@ -0,0 +1,188 @@
import { Expose, Type } from 'class-transformer';
import * as classValidator from 'class-validator';
import { IsDefined, IsNumber, Max, Min } from 'class-validator';
/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */
import { classValidatorResolver } from '..';
import { Schema, fields, invalidData, validData } from './__fixtures__/data';
const shouldUseNativeValidation = false;
describe('classValidatorResolver', () => {
it('should return values from classValidatorResolver when validation pass', async () => {
const schemaSpy = vi.spyOn(classValidator, 'validate');
const schemaSyncSpy = vi.spyOn(classValidator, 'validateSync');
const result = await classValidatorResolver(Schema)(validData, undefined, {
fields,
shouldUseNativeValidation,
});
expect(schemaSpy).toHaveBeenCalledTimes(1);
expect(schemaSyncSpy).not.toHaveBeenCalled();
expect(result).toEqual({ errors: {}, values: validData });
expect(result.values).toBeInstanceOf(Schema);
});
it('should return values as a raw object from classValidatorResolver when `rawValues` set to true', async () => {
const result = await classValidatorResolver(Schema, undefined, {
rawValues: true,
})(validData, undefined, {
fields,
shouldUseNativeValidation,
});
expect(result).toEqual({ errors: {}, values: validData });
expect(result.values).not.toBeInstanceOf(Schema);
});
it('should return values from classValidatorResolver with `mode: sync` when validation pass', async () => {
const validateSyncSpy = vi.spyOn(classValidator, 'validateSync');
const validateSpy = vi.spyOn(classValidator, 'validate');
const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(validData, undefined, { fields, shouldUseNativeValidation });
expect(validateSyncSpy).toHaveBeenCalledTimes(1);
expect(validateSpy).not.toHaveBeenCalled();
expect(result).toEqual({ errors: {}, values: validData });
expect(result.values).toBeInstanceOf(Schema);
});
it('should return a single error from classValidatorResolver when validation fails', async () => {
const result = await classValidatorResolver(Schema)(
invalidData,
undefined,
{
fields,
shouldUseNativeValidation,
},
);
expect(result).toMatchSnapshot();
});
it('should return a single error from classValidatorResolver with `mode: sync` when validation fails', async () => {
const validateSyncSpy = vi.spyOn(classValidator, 'validateSync');
const validateSpy = vi.spyOn(classValidator, 'validate');
const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(invalidData, undefined, { fields, shouldUseNativeValidation });
expect(validateSyncSpy).toHaveBeenCalledTimes(1);
expect(validateSpy).not.toHaveBeenCalled();
expect(result).toMatchSnapshot();
});
it('should return all the errors from classValidatorResolver when validation fails with `validateAllFieldCriteria` set to true', async () => {
const result = await classValidatorResolver(Schema)(
invalidData,
undefined,
{
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
},
);
expect(result).toMatchSnapshot();
});
it('should return all the errors from classValidatorResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => {
const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(invalidData, undefined, {
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
});
expect(result).toMatchSnapshot();
});
});
it('validate data with transformer option', async () => {
class SchemaTest {
@Expose({ groups: ['find', 'create', 'update'] })
@Type(() => Number)
@IsDefined({
message: `All fields must be defined.`,
groups: ['publish'],
})
@IsNumber({}, { message: `Must be a number`, always: true })
@Min(0, { message: `Cannot be lower than 0`, always: true })
@Max(255, { message: `Cannot be greater than 255`, always: true })
random: number;
}
const result = await classValidatorResolver(
SchemaTest,
{ transformer: { groups: ['update'] } },
{
mode: 'sync',
},
)(invalidData, undefined, {
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
});
expect(result).toMatchSnapshot();
});
it('validate data with validator option', async () => {
class SchemaTest {
@Expose({ groups: ['find', 'create', 'update'] })
@Type(() => Number)
@IsDefined({
message: `All fields must be defined.`,
groups: ['publish'],
})
@IsNumber({}, { message: `Must be a number`, always: true })
@Min(0, { message: `Cannot be lower than 0`, always: true })
@Max(255, { message: `Cannot be greater than 255`, always: true })
random: number;
}
const result = await classValidatorResolver(
SchemaTest,
{ validator: { stopAtFirstError: true } },
{
mode: 'sync',
},
)(invalidData, undefined, {
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
});
expect(result).toMatchSnapshot();
});
it('should return from classValidatorResolver with `excludeExtraneousValues` set to true', async () => {
class SchemaTest {
@Expose()
@IsNumber({}, { message: `Must be a number`, always: true })
random: number;
}
const result = await classValidatorResolver(SchemaTest, {
transformer: {
excludeExtraneousValues: true,
},
})(
{
random: 10,
extraneousField: true,
},
undefined,
{
fields,
shouldUseNativeValidation,
},
);
expect(result).toEqual({ errors: {}, values: { random: 10 } });
expect(result.values).toBeInstanceOf(SchemaTest);
});

View File

@ -0,0 +1,67 @@
import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
import { plainToClass } from 'class-transformer';
import { ValidationError, validate, validateSync } from 'class-validator';
import { FieldErrors } from 'react-hook-form';
import type { Resolver } from './types';
const parseErrors = (
errors: ValidationError[],
validateAllFieldCriteria: boolean,
parsedErrors: FieldErrors = {},
path = '',
) => {
return errors.reduce((acc, error) => {
const _path = path ? `${path}.${error.property}` : error.property;
if (error.constraints) {
const key = Object.keys(error.constraints)[0];
acc[_path] = {
type: key,
message: error.constraints[key],
};
const _e = acc[_path];
if (validateAllFieldCriteria && _e) {
Object.assign(_e, { types: error.constraints });
}
}
if (error.children && error.children.length) {
parseErrors(error.children, validateAllFieldCriteria, acc, _path);
}
return acc;
}, parsedErrors);
};
export const classValidatorResolver: Resolver =
(schema, schemaOptions = {}, resolverOptions = {}) =>
async (values, _, options) => {
const { transformer, validator } = schemaOptions;
const data = plainToClass(schema, values, transformer);
const rawErrors = await (resolverOptions.mode === 'sync'
? validateSync
: validate)(data, validator);
if (rawErrors.length) {
return {
values: {},
errors: toNestErrors(
parseErrors(
rawErrors,
!options.shouldUseNativeValidation &&
options.criteriaMode === 'all',
),
options,
),
};
}
options.shouldUseNativeValidation && validateFieldsNatively({}, options);
return {
values: resolverOptions.rawValues ? values : data,
errors: {},
};
};

View File

@ -0,0 +1,2 @@
export * from './class-validator';
export * from './types';

View File

@ -0,0 +1,16 @@
import { ClassConstructor, ClassTransformOptions } from 'class-transformer';
import { ValidatorOptions } from 'class-validator';
import { FieldValues, ResolverOptions, ResolverResult } from 'react-hook-form';
export type Resolver = <T extends { [_: string]: any }>(
schema: ClassConstructor<T>,
schemaOptions?: {
validator?: ValidatorOptions;
transformer?: ClassTransformOptions;
},
resolverOptions?: { mode?: 'async' | 'sync'; rawValues?: boolean },
) => <TFieldValues extends FieldValues, TContext>(
values: TFieldValues,
context: TContext | undefined,
options: ResolverOptions<TFieldValues>,
) => Promise<ResolverResult<TFieldValues>>;