Skip to content

Commit 5fc1e63

Browse files
authored
feat(fluentvalidation-ts): add fluentvalidation-ts resolver (#702)
1 parent 039385e commit 5fc1e63

File tree

13 files changed

+700
-9
lines changed

13 files changed

+700
-9
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
- [TypeSchema](#typeschema)
5151
- [effect-ts](#effect-ts)
5252
- [VineJS](#vinejs)
53+
- [fluentvalidation-ts](#fluentvalidation-ts)
5354
- [Backers](#backers)
5455
- [Sponsors](#sponsors)
5556
- [Contributors](#contributors)
@@ -734,6 +735,48 @@ const App = () => {
734735
};
735736
```
736737

738+
739+
### [fluentvalidation-ts](https://github.com/AlexJPotter/fluentvalidation-ts)
740+
741+
A TypeScript-first library for building strongly-typed validation rules
742+
743+
[![npm](https://img.shields.io/bundlephobia/minzip/@vinejs/vine?style=for-the-badge)](https://bundlephobia.com/result?p=@vinejs/vine)
744+
745+
```typescript jsx
746+
import { useForm } from 'react-hook-form';
747+
import { fluentValidationResolver } from '@hookform/resolvers/fluentvalidation-ts';
748+
import { Validator } from 'fluentvalidation-ts';
749+
750+
class FormDataValidator extends Validator<FormData> {
751+
constructor() {
752+
super();
753+
754+
this.ruleFor('username')
755+
.notEmpty()
756+
.withMessage('username is a required field');
757+
this.ruleFor('password')
758+
.notEmpty()
759+
.withMessage('password is a required field');
760+
}
761+
}
762+
763+
const App = () => {
764+
const { register, handleSubmit } = useForm({
765+
resolver: fluentValidationResolver(new FormDataValidator()),
766+
});
767+
768+
return (
769+
<form onSubmit={handleSubmit((d) => console.log(d))}>
770+
<input {...register('username')} />
771+
{errors.username && <span role="alert">{errors.username.message}</span>}
772+
<input {...register('password')} />
773+
{errors.password && <span role="alert">{errors.password.message}</span>}
774+
<button type="submit">submit</button>
775+
</form>
776+
);
777+
};
778+
```
779+
737780
## Backers
738781

739782
Thanks go to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].

bun.lockb

919 Bytes
Binary file not shown.

config/node-13-exports.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const subRepositories = [
1919
'typeschema',
2020
'effect-ts',
2121
'vine',
22+
'fluentvalidation-ts',
2223
];
2324

2425
const copySrc = () => {

fluentvalidation-ts/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@hookform/resolvers/fluentvalidation-ts",
3+
"amdName": "hookformResolversfluentvalidation-ts",
4+
"version": "1.0.0",
5+
"private": true,
6+
"description": "React Hook Form validation resolver: fluentvalidation-ts",
7+
"main": "dist/fluentvalidation-ts.js",
8+
"module": "dist/fluentvalidation-ts.module.js",
9+
"umd:main": "dist/fluentvalidation-ts.umd.js",
10+
"source": "src/index.ts",
11+
"types": "dist/index.d.ts",
12+
"license": "MIT",
13+
"peerDependencies": {
14+
"react-hook-form": "^7.0.0",
15+
"@hookform/resolvers": "^2.0.0",
16+
"fluentvalidation-ts": "^3.0.0"
17+
}
18+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { render, screen } from '@testing-library/react';
2+
import user from '@testing-library/user-event';
3+
import { Validator } from 'fluentvalidation-ts';
4+
import React from 'react';
5+
import { useForm } from 'react-hook-form';
6+
import { fluentValidationResolver } from '../fluentvalidation-ts';
7+
8+
const USERNAME_REQUIRED_MESSAGE = 'username field is required';
9+
const PASSWORD_REQUIRED_MESSAGE = 'password field is required';
10+
11+
type FormData = {
12+
username: string;
13+
password: string;
14+
};
15+
16+
class FormDataValidator extends Validator<FormData> {
17+
constructor() {
18+
super();
19+
20+
this.ruleFor('username').notEmpty().withMessage(USERNAME_REQUIRED_MESSAGE);
21+
this.ruleFor('password').notEmpty().withMessage(PASSWORD_REQUIRED_MESSAGE);
22+
}
23+
}
24+
25+
interface Props {
26+
onSubmit: (data: FormData) => void;
27+
}
28+
29+
function TestComponent({ onSubmit }: Props) {
30+
const { register, handleSubmit } = useForm<FormData>({
31+
resolver: fluentValidationResolver(new FormDataValidator()),
32+
shouldUseNativeValidation: true,
33+
});
34+
35+
return (
36+
<form onSubmit={handleSubmit(onSubmit)}>
37+
<input {...register('username')} placeholder="username" />
38+
39+
<input {...register('password')} placeholder="password" />
40+
41+
<button type="submit">submit</button>
42+
</form>
43+
);
44+
}
45+
46+
test("form's native validation with fluentvalidation-ts", async () => {
47+
const handleSubmit = vi.fn();
48+
render(<TestComponent onSubmit={handleSubmit} />);
49+
50+
// username
51+
let usernameField = screen.getByPlaceholderText(
52+
/username/i,
53+
) as HTMLInputElement;
54+
expect(usernameField.validity.valid).toBe(true);
55+
expect(usernameField.validationMessage).toBe('');
56+
57+
// password
58+
let passwordField = screen.getByPlaceholderText(
59+
/password/i,
60+
) as HTMLInputElement;
61+
expect(passwordField.validity.valid).toBe(true);
62+
expect(passwordField.validationMessage).toBe('');
63+
64+
await user.click(screen.getByText(/submit/i));
65+
66+
// username
67+
usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
68+
expect(usernameField.validity.valid).toBe(false);
69+
expect(usernameField.validationMessage).toBe(USERNAME_REQUIRED_MESSAGE);
70+
71+
// password
72+
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
73+
expect(passwordField.validity.valid).toBe(false);
74+
expect(passwordField.validationMessage).toBe(PASSWORD_REQUIRED_MESSAGE);
75+
76+
await user.type(screen.getByPlaceholderText(/username/i), 'joe');
77+
await user.type(screen.getByPlaceholderText(/password/i), 'password');
78+
79+
// username
80+
usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
81+
expect(usernameField.validity.valid).toBe(true);
82+
expect(usernameField.validationMessage).toBe('');
83+
84+
// password
85+
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
86+
expect(passwordField.validity.valid).toBe(true);
87+
expect(passwordField.validationMessage).toBe('');
88+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { render, screen } from '@testing-library/react';
2+
import user from '@testing-library/user-event';
3+
import { Validator } from 'fluentvalidation-ts';
4+
import React from 'react';
5+
import { SubmitHandler, useForm } from 'react-hook-form';
6+
import { fluentValidationResolver } from '../fluentvalidation-ts';
7+
8+
type FormData = {
9+
username: string;
10+
password: string;
11+
};
12+
13+
class FormDataValidator extends Validator<FormData> {
14+
constructor() {
15+
super();
16+
17+
this.ruleFor('username')
18+
.notEmpty()
19+
.withMessage('username is a required field');
20+
this.ruleFor('password')
21+
.notEmpty()
22+
.withMessage('password is a required field');
23+
}
24+
}
25+
26+
interface Props {
27+
onSubmit: SubmitHandler<FormData>;
28+
}
29+
30+
function TestComponent({ onSubmit }: Props) {
31+
const {
32+
register,
33+
formState: { errors },
34+
handleSubmit,
35+
} = useForm({
36+
resolver: fluentValidationResolver(new FormDataValidator()), // Useful to check TypeScript regressions
37+
});
38+
39+
return (
40+
<form onSubmit={handleSubmit(onSubmit)}>
41+
<input {...register('username')} />
42+
{errors.username && <span role="alert">{errors.username.message}</span>}
43+
44+
<input {...register('password')} />
45+
{errors.password && <span role="alert">{errors.password.message}</span>}
46+
47+
<button type="submit">submit</button>
48+
</form>
49+
);
50+
}
51+
52+
test("form's validation with Yup and TypeScript's integration", async () => {
53+
const handleSubmit = vi.fn();
54+
render(<TestComponent onSubmit={handleSubmit} />);
55+
56+
expect(screen.queryAllByRole('alert')).toHaveLength(0);
57+
58+
await user.click(screen.getByText(/submit/i));
59+
60+
expect(screen.getByText(/username is a required field/i)).toBeInTheDocument();
61+
expect(screen.getByText(/password is a required field/i)).toBeInTheDocument();
62+
expect(handleSubmit).not.toHaveBeenCalled();
63+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Validator } from 'fluentvalidation-ts';
2+
import { Field, InternalFieldName } from 'react-hook-form';
3+
4+
const beNumeric = (value: string | number | undefined) => !isNaN(Number(value));
5+
6+
export type Schema = {
7+
username: string;
8+
password: string;
9+
repeatPassword: string;
10+
accessToken?: string;
11+
birthYear?: number;
12+
email?: string;
13+
tags?: string[];
14+
enabled?: boolean;
15+
like?: {
16+
id: number;
17+
name: string;
18+
}[];
19+
};
20+
21+
export type SchemaWithWhen = {
22+
name: string;
23+
value: string;
24+
};
25+
26+
export class SchemaValidator extends Validator<Schema> {
27+
constructor() {
28+
super();
29+
30+
this.ruleFor('username')
31+
.notEmpty()
32+
.matches(/^\w+$/)
33+
.minLength(3)
34+
.maxLength(30);
35+
36+
this.ruleFor('password')
37+
.notEmpty()
38+
.matches(/.*[A-Z].*/)
39+
.withMessage('One uppercase character')
40+
.matches(/.*[a-z].*/)
41+
.withMessage('One lowercase character')
42+
.matches(/.*\d.*/)
43+
.withMessage('One number')
44+
.matches(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'))
45+
.withMessage('One special character')
46+
.minLength(8)
47+
.withMessage('Must be at least 8 characters in length');
48+
49+
this.ruleFor('repeatPassword')
50+
.notEmpty()
51+
.must((repeatPassword, data) => repeatPassword === data.password);
52+
53+
this.ruleFor('accessToken');
54+
this.ruleFor('birthYear')
55+
.must(beNumeric)
56+
.inclusiveBetween(1900, 2013)
57+
.when((birthYear) => birthYear != undefined);
58+
59+
this.ruleFor('email').emailAddress();
60+
this.ruleFor('tags');
61+
this.ruleFor('enabled');
62+
63+
this.ruleForEach('like').setValidator(() => new LikeValidator());
64+
}
65+
}
66+
67+
export class LikeValidator extends Validator<{
68+
id: number;
69+
name: string;
70+
}> {
71+
constructor() {
72+
super();
73+
74+
this.ruleFor('id').notNull();
75+
this.ruleFor('name').notEmpty().length(4, 4);
76+
}
77+
}
78+
79+
export const validData = {
80+
username: 'Doe',
81+
password: 'Password123_',
82+
repeatPassword: 'Password123_',
83+
birthYear: 2000,
84+
85+
tags: ['tag1', 'tag2'],
86+
enabled: true,
87+
accesstoken: 'accesstoken',
88+
like: [
89+
{
90+
id: 1,
91+
name: 'name',
92+
},
93+
],
94+
} as Schema;
95+
96+
export const invalidData = {
97+
password: '___',
98+
email: '',
99+
birthYear: 'birthYear',
100+
like: [{ id: 'z' }],
101+
// Must be set to "unknown", otherwise typescript knows that it is invalid
102+
} as unknown as Required<Schema>;
103+
104+
export const fields: Record<InternalFieldName, Field['_f']> = {
105+
username: {
106+
ref: { name: 'username' },
107+
name: 'username',
108+
},
109+
password: {
110+
ref: { name: 'password' },
111+
name: 'password',
112+
},
113+
email: {
114+
ref: { name: 'email' },
115+
name: 'email',
116+
},
117+
birthday: {
118+
ref: { name: 'birthday' },
119+
name: 'birthday',
120+
},
121+
};

0 commit comments

Comments
 (0)