Skip to content

Commit

Permalink
feat(validators): add built-in validation rules (#27)
Browse files Browse the repository at this point in the history
- export validation rules under validators namespace
- support early abort of validationg by throwing 'true'

Co-authored-by: Mikołaj Klaman <mklaman@virtuslab.com>
  • Loading branch information
mixvar and Mikołaj Klaman authored Nov 4, 2020
1 parent a9bb040 commit 4f52be0
Show file tree
Hide file tree
Showing 10 changed files with 655 additions and 9 deletions.
58 changes: 58 additions & 0 deletions src/core/builders/create-form-validator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert, IsExact } from "conditional-type-checks";

import { validators } from "../../validators";
import { FieldDescriptor } from "../types/field-descriptor";
import { FormValidator } from "../types/form-validator";
import { impl } from "../types/type-mapper-util";
Expand Down Expand Up @@ -606,6 +607,63 @@ describe("createFormValidator", () => {
);
});

it("should cancel validation when optional rule is used", async () => {
const rule1 = jest.fn().mockReturnValue("ERR_1");
const rule2 = jest.fn().mockReturnValue("ERR_2");

const { validate } = createFormValidator(Schema, validate => [
validate({
field: Schema.string,
rules: () => [validators.optional(), rule1, rule2],
}),
]);

const getValue = jest
.fn()
.mockReturnValueOnce("")
.mockReturnValueOnce("foo");

{
const result = await validate({ fields: [Schema.string], getValue });

expect(result).toEqual([
{ field: Schema.arrayString.nth(0), error: null },
]);
expect(rule1).not.toHaveBeenCalled();
expect(rule2).not.toHaveBeenCalled();
}

{
const result = await validate({ fields: [Schema.string], getValue });

expect(result).toEqual([
{ field: Schema.arrayString.nth(0), error: "ERR_1" },
]);
expect(rule1).toHaveBeenCalled();
expect(rule2).not.toHaveBeenCalled();
}
});

it("should re-throw any errors thrown by validation rules", async () => {
const error = new Error("test error");

const rule1 = jest.fn().mockReturnValue(null);
const rule2 = jest.fn().mockRejectedValue(error);

const { validate } = createFormValidator(Schema, validate => [
validate({
field: Schema.string,
rules: () => [rule1, rule2],
}),
]);

const getValue = jest.fn().mockReturnValue("foo");

await expect(() =>
validate({ fields: [Schema.string], getValue })
).rejects.toBe(error);
});

it("array validation should fire validation for each field", async () => {
const { validate } = createFormValidator(Schema, validate => [
validate({
Expand Down
24 changes: 19 additions & 5 deletions src/core/builders/create-form-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,18 @@ export const createFormValidator = <Values extends object, Err>(
onFieldValidationStart?.(field);
return firstNonNullPromise(validators, v =>
runValidationForField(v, value)
).then(error => {
onFieldValidationEnd?.(field);
return { field, error };
});
)
.catch(err => {
if (err === true) {
return null; // optional validator edge-case
}
onFieldValidationEnd?.(field);
throw err;
})
.then(error => {
onFieldValidationEnd?.(field);
return { field, error };
});
})
);
},
Expand Down Expand Up @@ -132,7 +140,13 @@ const runValidationForField = <Value, Err>(
.validators([] as any)
.filter(x => !isFalsy(x)) as Validator<Value, Err>[];

return firstNonNullPromise(rules, rule => Promise.resolve(rule(value)));
return firstNonNullPromise(rules, rule => {
try {
return Promise.resolve(rule(value));
} catch (err) {
return Promise.reject(err);
}
});
};

const firstNonNullPromise = <T, V>(
Expand Down
3 changes: 2 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { createForm } from "./builders";
export { useFormts } from "./hooks/use-formts";
export { useFormts, FormtsOptions } from "./hooks/use-formts";

export { FieldDecoder } from "./types/field-decoder";
export { FieldDescriptor } from "./types/field-descriptor";
export { FormSchema } from "./types/form-schema";
export { FormValidator, Validator } from "./types/form-validator";
10 changes: 7 additions & 3 deletions src/core/types/form-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ import {
*
* @returns validation error of type `Err`, or `null` when field is valid
*/
export type Validator<T, Err> = ValidatorSync<T, Err> | ValidatorAsync<T, Err>;
export type Validator<T, Err> =
| Validator.Sync<T, Err>
| Validator.Async<T, Err>;

export type ValidatorSync<T, Err> = (value: T) => Err | null;
export namespace Validator {
export type Sync<T, Err> = (value: T) => Err | null;

export type ValidatorAsync<T, Err> = (value: T) => Promise<Err | null>;
export type Async<T, Err> = (value: T) => Promise<Err | null>;
}

export type ValidationTrigger = "change" | "blur" | "submit";

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./core";
export * from "./validators";
10 changes: 10 additions & 0 deletions src/utils/utility-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ export type Falsy = null | undefined | false;
export const isFalsy = (x: unknown): x is Falsy => {
return x === null || x === undefined || x === false;
};

export type Primitive = string | number | boolean;

export type WidenType<T> = [T] extends [string]
? string
: [T] extends [number]
? number
: [T] extends [boolean]
? boolean
: T;
23 changes: 23 additions & 0 deletions src/validators/base-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Primitive } from "../utils/utility-types";

export type Required = { code: "required" };
export type OneOf = { code: "oneOf"; allowedValues: Primitive[] };

export type Integer = { code: "integer" };
export type MinValue = { code: "minValue"; min: number };
export type MaxValue = { code: "maxValue"; max: number };
export type GreaterThan = { code: "greaterThan"; threshold: number };
export type LesserThan = { code: "lesserThan"; threshold: number };

export type Pattern = { code: "pattern"; regex: RegExp };
export type HasSpecialChar = { code: "hasSpecialChar" };
export type HasUpperCaseChar = { code: "hasUpperCaseChar" };
export type HasLowerCaseChar = { code: "hasLowerCaseChar" };

export type MinLength = { code: "minLength"; min: number };
export type MaxLength = { code: "maxLength"; max: number };
export type ExactLength = { code: "exactLength"; expected: number };

export type ValidDate = { code: "validDate" };
export type MinDate = { code: "minDate"; min: Date };
export type MaxDate = { code: "maxDate"; max: Date };
4 changes: 4 additions & 0 deletions src/validators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// TODO: tree-shaking compatible way of importing validators
export * as validators from "./validators";

export * as BaseErrors from "./base-errors";
Loading

0 comments on commit 4f52be0

Please sign in to comment.