Skip to content

Commit

Permalink
feat: support browser native form control (#177)
Browse files Browse the repository at this point in the history
* feat: support browser native form control

* docs(README): update io-ts example
  • Loading branch information
jorisre authored Jun 19, 2021
1 parent 62c096e commit d8aff3d
Show file tree
Hide file tree
Showing 33 changed files with 937 additions and 82 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ const App = () => {

return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<input {...register('username'} />
<input {...register('username')} />
<input type="number" {...register('age')} />
<input type="submit" />
</form>
Expand Down
68 changes: 68 additions & 0 deletions class-validator/src/__tests__/Form-native-validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 { classValidatorResolver } from '..';
import { IsNotEmpty } from 'class-validator';

class Schema {
@IsNotEmpty()
username: string;

@IsNotEmpty()
password: string;
}

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

function TestComponent({ onSubmit }: Props) {
const { register, handleSubmit } = useForm<Schema>({
resolver: classValidatorResolver(Schema),
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 Class Validator", async () => {
const handleSubmit = jest.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 act(async () => {
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 should not be empty');

// password
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
expect(passwordField.validity.valid).toBe(false);
expect(passwordField.validationMessage).toBe('password should not be empty');
});
10 changes: 8 additions & 2 deletions class-validator/src/__tests__/class-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { classValidatorResolver } from '..';
import { Schema, validData, fields, invalidData } from './__fixtures__/data';
import * as classValidator from 'class-validator';

const shouldUseNativeValidation = false;

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

const result = await classValidatorResolver(Schema)(validData, undefined, {
fields,
shouldUseNativeValidation,
});

expect(schemaSpy).toHaveBeenCalledTimes(1);
Expand All @@ -23,7 +26,7 @@ describe('classValidatorResolver', () => {

const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(validData, undefined, { fields });
})(validData, undefined, { fields, shouldUseNativeValidation });

expect(validateSyncSpy).toHaveBeenCalledTimes(1);
expect(validateSpy).not.toHaveBeenCalled();
Expand All @@ -36,6 +39,7 @@ describe('classValidatorResolver', () => {
undefined,
{
fields,
shouldUseNativeValidation,
},
);

Expand All @@ -48,7 +52,7 @@ describe('classValidatorResolver', () => {

const result = await classValidatorResolver(Schema, undefined, {
mode: 'sync',
})(invalidData, undefined, { fields });
})(invalidData, undefined, { fields, shouldUseNativeValidation });

expect(validateSyncSpy).toHaveBeenCalledTimes(1);
expect(validateSpy).not.toHaveBeenCalled();
Expand All @@ -62,6 +66,7 @@ describe('classValidatorResolver', () => {
{
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
},
);

Expand All @@ -74,6 +79,7 @@ describe('classValidatorResolver', () => {
})(invalidData, undefined, {
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
});

expect(result).toMatchSnapshot();
Expand Down
8 changes: 6 additions & 2 deletions class-validator/src/class-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ export const classValidatorResolver: Resolver =
? {
values: {},
errors: toNestError(
parseErrors(rawErrors, options.criteriaMode === 'all'),
options.fields,
parseErrors(
rawErrors,
!options.shouldUseNativeValidation &&
options.criteriaMode === 'all',
),
options,
),
}
: { values, errors: {} };
Expand Down
70 changes: 70 additions & 0 deletions computed-types/src/__tests__/Form-native-validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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 Schema, { Type, string } from 'computed-types';
import { computedTypesResolver } from '..';

const USERNAME_REQUIRED_MESSAGE = 'username field is required';
const PASSWORD_REQUIRED_MESSAGE = 'password field is required';

const schema = Schema({
username: string.min(2).error(USERNAME_REQUIRED_MESSAGE),
password: string.min(2).error(PASSWORD_REQUIRED_MESSAGE),
});

type FormData = Type<typeof schema> & { unusedProperty: string };

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

function TestComponent({ onSubmit }: Props) {
const { register, handleSubmit } = useForm<FormData>({
resolver: computedTypesResolver(schema),
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 computed-types", async () => {
const handleSubmit = jest.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 act(async () => {
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);
});
6 changes: 5 additions & 1 deletion computed-types/src/__tests__/computed-types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { computedTypesResolver } from '..';
import { schema, validData, invalidData, fields } from './__fixtures__/data';

const shouldUseNativeValidation = false;

describe('computedTypesResolver', () => {
it('should return values from computedTypesResolver when validation pass', async () => {
const result = await computedTypesResolver(schema)(validData, undefined, {
fields,
shouldUseNativeValidation,
});

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

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

expect(result).toMatchSnapshot();
Expand Down
2 changes: 1 addition & 1 deletion computed-types/src/computed-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const computedTypesResolver: Resolver =
} catch (error) {
return {
values: {},
errors: toNestError(parseErrorSchema(error), options.fields),
errors: toNestError(parseErrorSchema(error), options),
};
}
};
74 changes: 74 additions & 0 deletions io-ts/src/__tests__/Form-native-validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 t from 'io-ts';
import * as tt from 'io-ts-types';
import { ioTsResolver } from '..';

const USERNAME_REQUIRED_MESSAGE = 'username field is required';
const PASSWORD_REQUIRED_MESSAGE = 'password field is required';

const schema = t.type({
username: tt.withMessage(tt.NonEmptyString, () => USERNAME_REQUIRED_MESSAGE),
password: tt.withMessage(tt.NonEmptyString, () => PASSWORD_REQUIRED_MESSAGE),
});

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

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

function TestComponent({ onSubmit }: Props) {
const { register, handleSubmit } = useForm<FormData>({
resolver: ioTsResolver(schema),
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 io-ts", async () => {
const handleSubmit = jest.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 act(async () => {
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);
});
5 changes: 5 additions & 0 deletions io-ts/src/__tests__/io-ts.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { ioTsResolver } from '..';
import { schema, validData, fields, invalidData } from './__fixtures__/data';

const shouldUseNativeValidation = false;

describe('ioTsResolver', () => {
it('should return values from ioTsResolver when validation pass', async () => {
const validateSpy = jest.spyOn(schema, 'decode');

const result = ioTsResolver(schema)(validData, undefined, {
fields,
shouldUseNativeValidation,
});

expect(validateSpy).toHaveBeenCalled();
Expand All @@ -16,6 +19,7 @@ describe('ioTsResolver', () => {
it('should return a single error from ioTsResolver when validation fails', () => {
const result = ioTsResolver(schema)(invalidData, undefined, {
fields,
shouldUseNativeValidation,
});

expect(result).toMatchSnapshot();
Expand All @@ -25,6 +29,7 @@ describe('ioTsResolver', () => {
const result = ioTsResolver(schema)(invalidData, undefined, {
fields,
criteriaMode: 'all',
shouldUseNativeValidation,
});

expect(result).toMatchSnapshot();
Expand Down
8 changes: 6 additions & 2 deletions io-ts/src/io-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ export const ioTsResolver: Resolver = (codec) => (values, _context, options) =>
pipe(
values,
codec.decode,
Either.mapLeft(errorsToRecord(options.criteriaMode === 'all')),
Either.mapLeft((errors) => toNestError(errors, options.fields)),
Either.mapLeft(
errorsToRecord(
!options.shouldUseNativeValidation && options.criteriaMode === 'all',
),
),
Either.mapLeft((errors) => toNestError(errors, options)),
Either.fold(
(errors) => ({
values: {},
Expand Down
Loading

0 comments on commit d8aff3d

Please sign in to comment.