Skip to content

Commit b7dd0e6

Browse files
authored
Fix: vest validate all criteria + better TypeSript support + add tests
* test: add components test to ensure TypeScript is working as expected Related to #97 * chore: improve zod resolvers types * chore(eslint): remove warnings * test: add severals tests & improve typings * chore: setup compressed-size * test: update describe
1 parent 8e10721 commit b7dd0e6

File tree

34 files changed

+1007
-628
lines changed

34 files changed

+1007
-628
lines changed

.eslintrc.js

+6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ module.exports = {
1818
'@typescript-eslint/explicit-function-return-type': 'off',
1919
'@typescript-eslint/no-object-literal-type-assertion': 'off',
2020
'no-console': 'error',
21+
'@typescript-eslint/no-unused-vars': [
22+
'error',
23+
{
24+
argsIgnorePattern: '^_',
25+
},
26+
],
2127
},
2228
overrides: [
2329
{

.github/workflows/compressedSize.yml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Compressed Size
2+
3+
on: [pull_request]
4+
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
9+
steps:
10+
- uses: actions/checkout@v2
11+
- uses: preactjs/compressed-size-action@v2
12+
with:
13+
repo-token: '${{ secrets.GITHUB_TOKEN }}'

jest.config.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
module.exports = {
22
preset: 'ts-jest',
3-
testEnvironment: 'node',
3+
testEnvironment: 'jsdom',
44
restoreMocks: true,
55
testMatch: ['**/__tests__/**/*.+(js|jsx|ts|tsx)'],
66
transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'],
77
moduleNameMapper: {
88
'^@hookform/resolvers$': '<rootDir>/src',
99
},
10+
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
1011
};

joi/src/__tests__/Form.tsx

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react';
2+
import { render, screen, act } from '@testing-library/react';
3+
import user from '@testing-library/user-event';
4+
import { useForm } from 'react-hook-form';
5+
import * as Joi from 'joi';
6+
import { joiResolver } from '..';
7+
8+
const schema = Joi.object({
9+
username: Joi.string().required(),
10+
password: Joi.string().required(),
11+
});
12+
13+
interface FormData {
14+
username: string;
15+
password: string;
16+
}
17+
18+
interface Props {
19+
onSubmit: (data: FormData) => void;
20+
}
21+
22+
function TestComponent({ onSubmit }: Props) {
23+
const { register, errors, handleSubmit } = useForm<FormData>({
24+
resolver: joiResolver(schema), // Useful to check TypeScript regressions
25+
});
26+
27+
return (
28+
<form onSubmit={handleSubmit(onSubmit)}>
29+
<input name="username" ref={register} />
30+
{errors.username && <span role="alert">{errors.username.message}</span>}
31+
32+
<input name="password" ref={register} />
33+
{errors.password && <span role="alert">{errors.password.message}</span>}
34+
35+
<button type="submit">submit</button>
36+
</form>
37+
);
38+
}
39+
40+
test("form's validation with Joi and TypeScript's integration", async () => {
41+
const handleSubmit = jest.fn();
42+
render(<TestComponent onSubmit={handleSubmit} />);
43+
44+
expect(screen.queryAllByRole(/alert/i)).toHaveLength(0);
45+
46+
await act(async () => {
47+
user.click(screen.getByText(/submit/i));
48+
});
49+
50+
expect(
51+
screen.getByText(/"username" is not allowed to be empty/i),
52+
).toBeInTheDocument();
53+
expect(
54+
screen.getByText(/"password" is not allowed to be empty/i),
55+
).toBeInTheDocument();
56+
expect(handleSubmit).not.toHaveBeenCalled();
57+
});

joi/src/__tests__/__snapshots__/joi.ts.snap

+71-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,81 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`joiResolver should return errors 1`] = `
3+
exports[`joiResolver should return a single error from joiResolver when validation fails 1`] = `
44
Object {
55
"errors": Object {
6+
"birthYear": Object {
7+
"message": "\\"birthYear\\" must be a number",
8+
"type": "number.base",
9+
},
10+
"email": Object {
11+
"message": "\\"email\\" is not allowed to be empty",
12+
"type": "string.empty",
13+
},
14+
"enabled": Object {
15+
"message": "\\"enabled\\" is required",
16+
"type": "any.required",
17+
},
18+
"password": Object {
19+
"message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
20+
"type": "string.pattern.base",
21+
},
22+
"tags": Object {
23+
"message": "\\"tags\\" is required",
24+
"type": "any.required",
25+
},
26+
"username": Object {
27+
"message": "\\"username\\" is required",
28+
"type": "any.required",
29+
},
30+
},
31+
"values": Object {},
32+
}
33+
`;
34+
35+
exports[`joiResolver should return all the errors from joiResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
36+
Object {
37+
"errors": Object {
38+
"birthYear": Object {
39+
"message": "\\"birthYear\\" must be a number",
40+
"type": "number.base",
41+
"types": Object {
42+
"number.base": "\\"birthYear\\" must be a number",
43+
},
44+
},
45+
"email": Object {
46+
"message": "\\"email\\" is not allowed to be empty",
47+
"type": "string.empty",
48+
"types": Object {
49+
"string.empty": "\\"email\\" is not allowed to be empty",
50+
},
51+
},
52+
"enabled": Object {
53+
"message": "\\"enabled\\" is required",
54+
"type": "any.required",
55+
"types": Object {
56+
"any.required": "\\"enabled\\" is required",
57+
},
58+
},
59+
"password": Object {
60+
"message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
61+
"type": "string.pattern.base",
62+
"types": Object {
63+
"string.pattern.base": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
64+
},
65+
},
66+
"tags": Object {
67+
"message": "\\"tags\\" is required",
68+
"type": "any.required",
69+
"types": Object {
70+
"any.required": "\\"tags\\" is required",
71+
},
72+
},
673
"username": Object {
774
"message": "\\"username\\" is required",
875
"type": "any.required",
76+
"types": Object {
77+
"any.required": "\\"username\\" is required",
78+
},
979
},
1080
},
1181
"values": Object {},

joi/src/__tests__/joi.ts

+50-14
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,67 @@ import { joiResolver } from '..';
33

44
const schema = Joi.object({
55
username: Joi.string().alphanum().min(3).max(30).required(),
6-
7-
password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
8-
6+
password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')).required(),
97
repeatPassword: Joi.ref('password'),
10-
118
accessToken: [Joi.string(), Joi.number()],
12-
139
birthYear: Joi.number().integer().min(1900).max(2013),
14-
1510
email: Joi.string().email({
1611
minDomainSegments: 2,
1712
tlds: { allow: ['com', 'net'] },
1813
}),
14+
tags: Joi.array().items(Joi.string()).required(),
15+
enabled: Joi.boolean().required(),
1916
});
2017

18+
interface Data {
19+
username: string;
20+
password: string;
21+
repeatPassword: string;
22+
accessToken?: number | string;
23+
birthYear?: number;
24+
email?: string;
25+
tags: string[];
26+
enabled: boolean;
27+
}
28+
2129
describe('joiResolver', () => {
22-
it('should return correct value', async () => {
23-
const data = { username: 'abc', birthYear: 1994 };
24-
expect(await joiResolver(schema)(data)).toEqual({
25-
values: data,
26-
errors: {},
27-
});
30+
it('should return values from joiResolver when validation pass', async () => {
31+
const data: Data = {
32+
username: 'Doe',
33+
password: 'Password123',
34+
repeatPassword: 'Password123',
35+
birthYear: 2000,
36+
email: 'john@doe.com',
37+
tags: ['tag1', 'tag2'],
38+
enabled: true,
39+
};
40+
41+
const result = await joiResolver(schema)(data);
42+
43+
expect(result).toEqual({ errors: {}, values: data });
2844
});
2945

30-
it('should return errors', async () => {
31-
expect(await joiResolver(schema)({})).toMatchSnapshot();
46+
it('should return a single error from joiResolver when validation fails', async () => {
47+
const data = {
48+
password: '___',
49+
email: '',
50+
birthYear: 'birthYear',
51+
};
52+
53+
const result = await joiResolver(schema)(data);
54+
55+
expect(result).toMatchSnapshot();
56+
});
57+
58+
it('should return all the errors from joiResolver when validation fails with `validateAllFieldCriteria` set to true', async () => {
59+
const data = {
60+
password: '___',
61+
email: '',
62+
birthYear: 'birthYear',
63+
};
64+
65+
const result = await joiResolver(schema)(data, undefined, true);
66+
67+
expect(result).toMatchSnapshot();
3268
});
3369
});

joi/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './joi';
2+
export * from './types';

joi/src/joi.ts

+6-14
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import {
2-
appendErrors,
3-
transformToNestObject,
4-
Resolver,
5-
FieldValues,
6-
} from 'react-hook-form';
1+
import { appendErrors, transformToNestObject } from 'react-hook-form';
72
import * as Joi from 'joi';
83
// @ts-expect-error maybe fixed after the first publish ?
94
import { convertArrayToPathName } from '@hookform/resolvers';
5+
import { Resolver } from './types';
106

117
const parseErrorSchema = (
128
error: Joi.ValidationError,
@@ -48,16 +44,12 @@ const parseErrorSchema = (
4844
)
4945
: [];
5046

51-
export const joiResolver = <TFieldValues extends FieldValues>(
52-
schema: Joi.Schema,
53-
options: Joi.AsyncValidationOptions = {
47+
export const joiResolver: Resolver = (
48+
schema,
49+
options = {
5450
abortEarly: false,
5551
},
56-
): Resolver<TFieldValues> => async (
57-
values,
58-
_,
59-
validateAllFieldCriteria = false,
60-
) => {
52+
) => async (values, _, validateAllFieldCriteria = false) => {
6153
try {
6254
return {
6355
values: await schema.validateAsync(values, {

joi/src/types.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
FieldValues,
3+
ResolverResult,
4+
UnpackNestedValue,
5+
} from 'react-hook-form';
6+
import type { AsyncValidationOptions, Schema } from 'joi';
7+
8+
export type Resolver = <T extends Schema>(
9+
schema: T,
10+
options?: AsyncValidationOptions,
11+
) => <TFieldValues extends FieldValues, TContext>(
12+
values: UnpackNestedValue<TFieldValues>,
13+
context?: TContext,
14+
validateAllFieldCriteria?: boolean,
15+
) => Promise<ResolverResult<TFieldValues>>;

package.json

+5
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@
109109
},
110110
"homepage": "https://react-hook-form.com",
111111
"devDependencies": {
112+
"@testing-library/jest-dom": "^5.11.8",
113+
"@testing-library/react": "^11.2.2",
114+
"@testing-library/user-event": "^12.6.0",
112115
"@types/jest": "^26.0.19",
116+
"@types/react": "^17.0.0",
113117
"@typescript-eslint/eslint-plugin": "^4.11.1",
114118
"@typescript-eslint/parser": "^4.11.1",
115119
"check-export-map": "^1.0.1",
@@ -124,6 +128,7 @@
124128
"npm-run-all": "^4.1.5",
125129
"prettier": "^2.2.1",
126130
"react": "^17.0.1",
131+
"react-dom": "^17.0.1",
127132
"react-hook-form": "^6.14.0",
128133
"semantic-release": "^17.3.1",
129134
"superstruct": "^0.13.1",

superstruct/src/__tests__/Form.tsx

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
import { render, screen, act } from '@testing-library/react';
3+
import user from '@testing-library/user-event';
4+
import { useForm } from 'react-hook-form';
5+
import { object, string, Infer, size } from 'superstruct';
6+
import { superstructResolver } from '..';
7+
8+
const schema = object({
9+
username: size(string(), 2),
10+
password: size(string(), 6),
11+
});
12+
13+
type FormData = Infer<typeof schema>;
14+
15+
interface Props {
16+
onSubmit: (data: FormData) => void;
17+
}
18+
19+
function TestComponent({ onSubmit }: Props) {
20+
const { register, errors, handleSubmit } = useForm<FormData>({
21+
resolver: superstructResolver(schema), // Useful to check TypeScript regressions
22+
});
23+
24+
return (
25+
<form onSubmit={handleSubmit(onSubmit)}>
26+
<input name="username" ref={register} />
27+
{errors.username && <span role="alert">{errors.username.message}</span>}
28+
29+
<input name="password" ref={register} />
30+
{errors.password && <span role="alert">{errors.password.message}</span>}
31+
32+
<button type="submit">submit</button>
33+
</form>
34+
);
35+
}
36+
37+
test("form's validation with Superstruct and TypeScript's integration", async () => {
38+
const handleSubmit = jest.fn();
39+
render(<TestComponent onSubmit={handleSubmit} />);
40+
41+
expect(screen.queryAllByRole(/alert/i)).toHaveLength(0);
42+
43+
await act(async () => {
44+
user.click(screen.getByText(/submit/i));
45+
});
46+
47+
expect(
48+
screen.getByText(
49+
/Expected a string with a length of `2` but received one with a length of `0`/i,
50+
),
51+
).toBeInTheDocument();
52+
expect(
53+
screen.getByText(
54+
/Expected a string with a length of `6` but received one with a length of `0`/i,
55+
),
56+
).toBeInTheDocument();
57+
expect(handleSubmit).not.toHaveBeenCalled();
58+
});

0 commit comments

Comments
 (0)