Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fluentvalidation-ts resolver #651

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/node-13-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const subRepositories = [
'typebox',
'arktype',
'valibot',
'fluentvalidation-ts',
];

const copySrc = () => {
Expand Down
18 changes: 18 additions & 0 deletions fluentvalidation-ts/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
88 changes: 88 additions & 0 deletions fluentvalidation-ts/src/__tests__/Form-native-validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 { Validator } from 'fluentvalidation-ts';
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<FormData> {
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<FormData>({
resolver: fluentValidationResolver(new FormDataValidator()),
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 fluentvalidation-ts", 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_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('');
});
63 changes: 63 additions & 0 deletions fluentvalidation-ts/src/__tests__/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { SubmitHandler, useForm } from 'react-hook-form';
import { fluentValidationResolver } from '../fluentvalidation-ts';
import { Validator } from 'fluentvalidation-ts';

type FormData = {
username: string;
password: string;
};

class FormDataValidator extends Validator<FormData> {
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<FormData>;
}

function TestComponent({ onSubmit }: Props) {
const {
register,
formState: { errors },
handleSubmit,
} = useForm({
resolver: fluentValidationResolver(new FormDataValidator()), // Useful to check TypeScript regressions
});

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 Yup 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 is a required field/i)).toBeInTheDocument();
expect(screen.getByText(/password is a required field/i)).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
121 changes: 121 additions & 0 deletions fluentvalidation-ts/src/__tests__/__fixtures__/data.ts
Original file line number Diff line number Diff line change
@@ -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<Schema> {
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<Schema>;

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',
},
};
Loading
Loading