Skip to content

Commit

Permalink
feat: nope resolver (#158)
Browse files Browse the repository at this point in the history
* fix(zodResolver): missing errors when criteriaMode: 'all'

* fix(joiResolver): missing errors when criteriaMode: 'all'

* feat: nope resolver
  • Loading branch information
jorisre committed Apr 17, 2021
1 parent 2ff9b66 commit c8b961b
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 3 deletions.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,40 @@ const App = () => {
export default App;
```

### [Nope](https://github.com/bvego/nope-validator)

A small, simple, and fast JS validator

[![npm](https://img.shields.io/bundlephobia/minzip/nope-validator?style=for-the-badge)](https://bundlephobia.com/result?p=nope-validator)

```typescript jsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { nopeResolver } from '@hookform/resolvers/nope';
import Nope from 'nope-validator';

const schema = Nope.object().shape({
name: Nope.string().required(),
age: Nope.number().required(),
});

const App = () => {
const { register, handleSubmit } = useForm({
resolver: nopeResolver(schema),
});

return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<input name="name" ref={register} />
<input name="age" type="number" ref={register} />
<input type="submit" />
</form>
);
};

export default App;
```

## Backers

Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].
Expand Down
1 change: 1 addition & 0 deletions config/node-13-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const subRepositories = [
'superstruct',
'class-validator',
'io-ts',
'nope',
];

const copySrc = () => {
Expand Down
18 changes: 18 additions & 0 deletions nope/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "nope",
"amdName": "hookformResolversNope",
"version": "1.0.0",
"private": true,
"description": "React Hook Form validation resolver: nope",
"main": "dist/nope.js",
"module": "dist/nope.module.js",
"umd:main": "dist/nope.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",
"nope-validator": "^0.12.0"
}
}
57 changes: 57 additions & 0 deletions nope/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 Nope from 'nope-validator';
import { nopeResolver } from '..';

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

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

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

function TestComponent({ onSubmit }: Props) {
const {
register,
formState: { errors },
handleSubmit,
} = useForm<FormData>({
resolver: nopeResolver(schema), // 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 = jest.fn();
render(<TestComponent onSubmit={handleSubmit} />);

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

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

expect(screen.getAllByText(/This field is required/i)).toHaveLength(2);
expect(handleSubmit).not.toHaveBeenCalled();
});
70 changes: 70 additions & 0 deletions nope/src/__tests__/__fixtures__/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Field, InternalFieldName } from 'react-hook-form';
import Nope from 'nope-validator';

export const schema = Nope.object().shape({
username: Nope.string().regex(/^\w+$/).min(2).max(30).required(),
password: Nope.string()
.regex(new RegExp('.*[A-Z].*'), 'One uppercase character')
.regex(new RegExp('.*[a-z].*'), 'One lowercase character')
.regex(new RegExp('.*\\d.*'), 'One number')
.regex(
new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'),
'One special character',
)
.min(8, 'Must be at least 8 characters in length')
.required('New Password is required'),
repeatPassword: Nope.string()
.oneOf([Nope.ref('password')], "Passwords don't match")
.required(),
accessToken: Nope.string(),
birthYear: Nope.number().min(1900).max(2013),
email: Nope.string().email(),
tags: Nope.array().of(Nope.string()).required(),
enabled: Nope.boolean(),
like: Nope.object().shape({
id: Nope.number().required(),
name: Nope.string().atLeast(4).required(),
}),
});

export const validData = {
username: 'Doe',
password: 'Password123_',
repeatPassword: 'Password123_',
birthYear: 2000,
email: '[email protected]',
tags: ['tag1', 'tag2'],
enabled: true,
accessToken: 'accessToken',
like: {
id: 1,
name: 'name',
},
};

export const invalidData = {
password: '___',
email: '',
birthYear: 'birthYear',
like: { id: 'z' },
tags: [1, 2, 3],
};

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',
},
};
43 changes: 43 additions & 0 deletions nope/src/__tests__/__snapshots__/nope.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`nopeResolver should return a single error from nopeResolver when validation fails 1`] = `
Object {
"errors": Object {
"birthYear": Object {
"message": "The field is not a valid number",
"ref": undefined,
},
"like": Object {
"id": Object {
"message": "The field is not a valid number",
"ref": undefined,
},
"name": Object {
"message": "This field is required",
"ref": undefined,
},
},
"password": Object {
"message": "One uppercase character",
"ref": Object {
"name": "password",
},
},
"repeatPassword": Object {
"message": "This field is required",
"ref": undefined,
},
"tags": Object {
"message": "One or more elements are of invalid type",
"ref": undefined,
},
"username": Object {
"message": "This field is required",
"ref": Object {
"name": "username",
},
},
},
"values": Object {},
}
`;
24 changes: 24 additions & 0 deletions nope/src/__tests__/nope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* eslint-disable no-console, @typescript-eslint/ban-ts-comment */
import { nopeResolver } from '..';
import { schema, validData, fields, invalidData } from './__fixtures__/data';

describe('nopeResolver', () => {
it('should return values from nopeResolver when validation pass', async () => {
const schemaSpy = jest.spyOn(schema, 'validate');

const result = await nopeResolver(schema)(validData, undefined, {
fields,
});

expect(schemaSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual({ errors: {}, values: validData });
});

it('should return a single error from nopeResolver when validation fails', async () => {
const result = await nopeResolver(schema)(invalidData, undefined, {
fields,
});

expect(result).toMatchSnapshot();
});
});
2 changes: 2 additions & 0 deletions nope/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './nope';
export * from './types';
40 changes: 40 additions & 0 deletions nope/src/nope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { FieldErrors } from 'react-hook-form';
import { toNestError } from '@hookform/resolvers';
import type { ShapeErrors } from 'nope-validator/lib/cjs/types';
import type { Resolver } from './types';

const parseErrors = (
errors: ShapeErrors,
parsedErrors: FieldErrors = {},
path = '',
) => {
return Object.keys(errors).reduce((acc, key) => {
const _path = path ? `${path}.${key}` : key;
const error = errors[key];

if (typeof error === 'string') {
acc[_path] = {
message: error,
};
} else {
parseErrors(error, acc, _path);
}

return acc;
}, parsedErrors);
};

export const nopeResolver: Resolver = (
schema,
schemaOptions = {
abortEarly: false,
},
) => (values, context, options) => {
const result = schema.validate(values, context, schemaOptions) as
| ShapeErrors
| undefined;

return result
? { values: {}, errors: toNestError(parseErrors(result), options.fields) }
: { values, errors: {} };
};
19 changes: 19 additions & 0 deletions nope/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {
FieldValues,
ResolverOptions,
ResolverResult,
UnpackNestedValue,
} from 'react-hook-form';
import type NopeObject from 'nope-validator/lib/cjs/NopeObject';

type ValidateOptions = Parameters<NopeObject['validate']>[2];
type Context = Parameters<NopeObject['validate']>[1];

export type Resolver = <T extends NopeObject>(
schema: T,
schemaOptions?: ValidateOptions,
) => <TFieldValues extends FieldValues, TContext extends Context>(
values: UnpackNestedValue<TFieldValues>,
context: TContext | undefined,
options: ResolverOptions<TFieldValues>,
) => ResolverResult<TFieldValues>;
18 changes: 15 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@hookform/resolvers",
"amdName": "hookformResolvers",
"version": "1.3.1",
"description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator and etc.",
"description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts and Nope.",
"main": "dist/resolvers.js",
"module": "dist/resolvers.module.js",
"umd:main": "dist/resolvers.umd.js",
Expand Down Expand Up @@ -57,6 +57,12 @@
"import": "./io-ts/dist/io-ts.mjs",
"require": "./io-ts/dist/io-ts.js"
},
"./nope": {
"browser": "./nope/dist/nope.module.js",
"umd": "./nope/dist/nope.umd.js",
"import": "./nope/dist/nope.mjs",
"require": "./nope/dist/nope.js"
},
"./package.json": "./package.json",
"./": "./"
},
Expand All @@ -82,7 +88,10 @@
"class-validator/dist",
"io-ts/package.json",
"io-ts/src",
"io-ts/dist"
"io-ts/dist",
"nope/package.json",
"nope/src",
"nope/dist"
],
"publishConfig": {
"access": "public"
Expand All @@ -98,6 +107,7 @@
"build:io-ts": "microbundle --cwd io-ts --globals '@hookform/resolvers=hookformResolvers'",
"build:vest": "microbundle --cwd vest --globals '@hookform/resolvers=hookformResolvers'",
"build:class-validator": "microbundle --cwd class-validator --globals '@hookform/resolvers=hookformResolvers'",
"build:nope": "microbundle --cwd nope --globals '@hookform/resolvers=hookformResolvers'",
"postbuild": "node ./config/node-13-exports.js",
"lint": "eslint . --ext .ts,.js --ignore-path .gitignore",
"lint:types": "tsc",
Expand All @@ -118,7 +128,8 @@
"zod",
"vest",
"class-validator",
"io-ts"
"io-ts",
"nope"
],
"repository": {
"type": "git",
Expand Down Expand Up @@ -154,6 +165,7 @@
"microbundle": "^0.13.0",
"monocle-ts": "^2.3.9",
"newtype-ts": "^0.3.4",
"nope-validator": "^0.12.2",
"npm-run-all": "^4.1.5",
"prettier": "^2.2.1",
"react": "^17.0.1",
Expand Down
Loading

0 comments on commit c8b961b

Please sign in to comment.