Skip to content

Commit

Permalink
Fix: vest validate all criteria + better TypeSript support + add tests
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jorisre authored Jan 9, 2021
1 parent 8e10721 commit b7dd0e6
Show file tree
Hide file tree
Showing 34 changed files with 1,007 additions and 628 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-object-literal-type-assertion': 'off',
'no-console': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
},
],
},
overrides: [
{
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/compressedSize.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Compressed Size

on: [pull_request]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: preactjs/compressed-size-action@v2
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testEnvironment: 'jsdom',
restoreMocks: true,
testMatch: ['**/__tests__/**/*.+(js|jsx|ts|tsx)'],
transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'],
moduleNameMapper: {
'^@hookform/resolvers$': '<rootDir>/src',
},
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
};
57 changes: 57 additions & 0 deletions joi/src/__tests__/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import user from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import * as Joi from 'joi';
import { joiResolver } from '..';

const schema = Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
});

interface FormData {
username: string;
password: string;
}

interface Props {
onSubmit: (data: FormData) => void;
}

function TestComponent({ onSubmit }: Props) {
const { register, errors, handleSubmit } = useForm<FormData>({
resolver: joiResolver(schema), // Useful to check TypeScript regressions
});

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input name="username" ref={register} />
{errors.username && <span role="alert">{errors.username.message}</span>}

<input name="password" ref={register} />
{errors.password && <span role="alert">{errors.password.message}</span>}

<button type="submit">submit</button>
</form>
);
}

test("form's validation with Joi and TypeScript's integration", async () => {
const handleSubmit = jest.fn();
render(<TestComponent onSubmit={handleSubmit} />);

expect(screen.queryAllByRole(/alert/i)).toHaveLength(0);

await act(async () => {
user.click(screen.getByText(/submit/i));
});

expect(
screen.getByText(/"username" is not allowed to be empty/i),
).toBeInTheDocument();
expect(
screen.getByText(/"password" is not allowed to be empty/i),
).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
72 changes: 71 additions & 1 deletion joi/src/__tests__/__snapshots__/joi.ts.snap
Original file line number Diff line number Diff line change
@@ -1,11 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`joiResolver should return errors 1`] = `
exports[`joiResolver should return a single error from joiResolver when validation fails 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "\\"birthYear\\" must be a number",
"type": "number.base",
},
"email": Object {
"message": "\\"email\\" is not allowed to be empty",
"type": "string.empty",
},
"enabled": Object {
"message": "\\"enabled\\" is required",
"type": "any.required",
},
"password": Object {
"message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
"type": "string.pattern.base",
},
"tags": Object {
"message": "\\"tags\\" is required",
"type": "any.required",
},
"username": Object {
"message": "\\"username\\" is required",
"type": "any.required",
},
},
"values": Object {},
}
`;

exports[`joiResolver should return all the errors from joiResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "\\"birthYear\\" must be a number",
"type": "number.base",
"types": Object {
"number.base": "\\"birthYear\\" must be a number",
},
},
"email": Object {
"message": "\\"email\\" is not allowed to be empty",
"type": "string.empty",
"types": Object {
"string.empty": "\\"email\\" is not allowed to be empty",
},
},
"enabled": Object {
"message": "\\"enabled\\" is required",
"type": "any.required",
"types": Object {
"any.required": "\\"enabled\\" is required",
},
},
"password": Object {
"message": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
"type": "string.pattern.base",
"types": Object {
"string.pattern.base": "\\"password\\" with value \\"___\\" fails to match the required pattern: /^[a-zA-Z0-9]{3,30}$/",
},
},
"tags": Object {
"message": "\\"tags\\" is required",
"type": "any.required",
"types": Object {
"any.required": "\\"tags\\" is required",
},
},
"username": Object {
"message": "\\"username\\" is required",
"type": "any.required",
"types": Object {
"any.required": "\\"username\\" is required",
},
},
},
"values": Object {},
Expand Down
64 changes: 50 additions & 14 deletions joi/src/__tests__/joi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,67 @@ import { joiResolver } from '..';

const schema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),

password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),

password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')).required(),
repeatPassword: Joi.ref('password'),

accessToken: [Joi.string(), Joi.number()],

birthYear: Joi.number().integer().min(1900).max(2013),

email: Joi.string().email({
minDomainSegments: 2,
tlds: { allow: ['com', 'net'] },
}),
tags: Joi.array().items(Joi.string()).required(),
enabled: Joi.boolean().required(),
});

interface Data {
username: string;
password: string;
repeatPassword: string;
accessToken?: number | string;
birthYear?: number;
email?: string;
tags: string[];
enabled: boolean;
}

describe('joiResolver', () => {
it('should return correct value', async () => {
const data = { username: 'abc', birthYear: 1994 };
expect(await joiResolver(schema)(data)).toEqual({
values: data,
errors: {},
});
it('should return values from joiResolver when validation pass', async () => {
const data: Data = {
username: 'Doe',
password: 'Password123',
repeatPassword: 'Password123',
birthYear: 2000,
email: 'john@doe.com',
tags: ['tag1', 'tag2'],
enabled: true,
};

const result = await joiResolver(schema)(data);

expect(result).toEqual({ errors: {}, values: data });
});

it('should return errors', async () => {
expect(await joiResolver(schema)({})).toMatchSnapshot();
it('should return a single error from joiResolver when validation fails', async () => {
const data = {
password: '___',
email: '',
birthYear: 'birthYear',
};

const result = await joiResolver(schema)(data);

expect(result).toMatchSnapshot();
});

it('should return all the errors from joiResolver when validation fails with `validateAllFieldCriteria` set to true', async () => {
const data = {
password: '___',
email: '',
birthYear: 'birthYear',
};

const result = await joiResolver(schema)(data, undefined, true);

expect(result).toMatchSnapshot();
});
});
1 change: 1 addition & 0 deletions joi/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './joi';
export * from './types';
20 changes: 6 additions & 14 deletions joi/src/joi.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import {
appendErrors,
transformToNestObject,
Resolver,
FieldValues,
} from 'react-hook-form';
import { appendErrors, transformToNestObject } from 'react-hook-form';
import * as Joi from 'joi';
// @ts-expect-error maybe fixed after the first publish ?
import { convertArrayToPathName } from '@hookform/resolvers';
import { Resolver } from './types';

const parseErrorSchema = (
error: Joi.ValidationError,
Expand Down Expand Up @@ -48,16 +44,12 @@ const parseErrorSchema = (
)
: [];

export const joiResolver = <TFieldValues extends FieldValues>(
schema: Joi.Schema,
options: Joi.AsyncValidationOptions = {
export const joiResolver: Resolver = (
schema,
options = {
abortEarly: false,
},
): Resolver<TFieldValues> => async (
values,
_,
validateAllFieldCriteria = false,
) => {
) => async (values, _, validateAllFieldCriteria = false) => {
try {
return {
values: await schema.validateAsync(values, {
Expand Down
15 changes: 15 additions & 0 deletions joi/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
FieldValues,
ResolverResult,
UnpackNestedValue,
} from 'react-hook-form';
import type { AsyncValidationOptions, Schema } from 'joi';

export type Resolver = <T extends Schema>(
schema: T,
options?: AsyncValidationOptions,
) => <TFieldValues extends FieldValues, TContext>(
values: UnpackNestedValue<TFieldValues>,
context?: TContext,
validateAllFieldCriteria?: boolean,
) => Promise<ResolverResult<TFieldValues>>;
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@
},
"homepage": "https://react-hook-form.com",
"devDependencies": {
"@testing-library/jest-dom": "^5.11.8",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"@types/jest": "^26.0.19",
"@types/react": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^4.11.1",
"@typescript-eslint/parser": "^4.11.1",
"check-export-map": "^1.0.1",
Expand All @@ -124,6 +128,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^2.2.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-hook-form": "^6.14.0",
"semantic-release": "^17.3.1",
"superstruct": "^0.13.1",
Expand Down
58 changes: 58 additions & 0 deletions superstruct/src/__tests__/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import user from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import { object, string, Infer, size } from 'superstruct';
import { superstructResolver } from '..';

const schema = object({
username: size(string(), 2),
password: size(string(), 6),
});

type FormData = Infer<typeof schema>;

interface Props {
onSubmit: (data: FormData) => void;
}

function TestComponent({ onSubmit }: Props) {
const { register, errors, handleSubmit } = useForm<FormData>({
resolver: superstructResolver(schema), // Useful to check TypeScript regressions
});

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input name="username" ref={register} />
{errors.username && <span role="alert">{errors.username.message}</span>}

<input name="password" ref={register} />
{errors.password && <span role="alert">{errors.password.message}</span>}

<button type="submit">submit</button>
</form>
);
}

test("form's validation with Superstruct and TypeScript's integration", async () => {
const handleSubmit = jest.fn();
render(<TestComponent onSubmit={handleSubmit} />);

expect(screen.queryAllByRole(/alert/i)).toHaveLength(0);

await act(async () => {
user.click(screen.getByText(/submit/i));
});

expect(
screen.getByText(
/Expected a string with a length of `2` but received one with a length of `0`/i,
),
).toBeInTheDocument();
expect(
screen.getByText(
/Expected a string with a length of `6` but received one with a length of `0`/i,
),
).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
Loading

0 comments on commit b7dd0e6

Please sign in to comment.