Skip to content

Commit

Permalink
feat: debounced validation (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
pidkopajo authored Feb 22, 2021
1 parent a4c2581 commit 3a04328
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 17 deletions.
217 changes: 217 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 { Task } from "../../utils/task";
import { validators } from "../../validators";
import { FieldDescriptor } from "../types/field-descriptor";
import { FormValidator } from "../types/form-validator";
Expand Down Expand Up @@ -1095,3 +1096,219 @@ describe("createFormValidator", () => {
expect(validation).toEqual([{ field: Schema.string, error: null }]);
});
});

describe("debounced validation", () => {
const Schema = createFormSchema(
fields => ({
string: fields.string(),
number: fields.number(),
}),
error => error<"REQUIRED" | "TOO_SHORT" | "INVALID_VALUE">()
);
const createFormValidatorImpl = (
...args: Parameters<typeof createFormValidator>
) => impl(createFormValidator(...args));

it("should debounce when single-rule debounced validator called >1 times", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator],
debounce: 100,
}),
]);

const getValue = () => "" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue })
).runPromise()
).toEqual([[], [], [{ field: Schema.string, error: "REQUIRED" }]]);
expect(stringRequiredValidator).toHaveBeenCalledTimes(1);
});

it("should debounce when multi-rule debounced validator called >1 times", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);
const stringLengthValidator = jest.fn((x: string) =>
x.length > 3 ? null : "TOO_SHORT"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator, stringLengthValidator],
debounce: 100,
}),
]);

const getValue = () => "ab" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue })
).runPromise()
).toEqual([[], [], [{ field: Schema.string, error: "TOO_SHORT" }]]);
expect(stringRequiredValidator).toHaveBeenCalledTimes(1);
expect(stringLengthValidator).toHaveBeenCalledTimes(1);
});

it("should not debounce when called after debounce timeout", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator],
debounce: 100,
}),
]);

const getValue = () => "ab" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
Task.make(({ resolve, reject }) => {
setTimeout(
() =>
validate({ fields: [Schema.string], getValue })
.runPromise()
.then(resolve)
.catch(reject),
200
);
})
).runPromise()
).toEqual([
[{ field: Schema.string, error: null }],
[{ field: Schema.string, error: null }],
]);

expect(stringRequiredValidator).toHaveBeenCalledTimes(2);
});

it("should not interfere with non-debounced fields", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);
const numberLengthValidator = jest.fn((x: number | "") =>
x ? null : "REQUIRED"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator],
debounce: 100,
}),
validate(Schema.number, numberLengthValidator),
]);

const getValue = () => "" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.number], getValue }),
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.number], getValue })
).runPromise()
).toEqual([
[],
[{ field: Schema.number, error: "REQUIRED" }],
[{ field: Schema.string, error: "REQUIRED" }],
[{ field: Schema.number, error: "REQUIRED" }],
]);

expect(stringRequiredValidator).toHaveBeenCalledTimes(1);
expect(numberLengthValidator).toHaveBeenCalledTimes(2);
});

it("should properly debounce multiple debounced validators for the same field", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);
const stringLengthValidator = jest.fn((x: string) =>
x.length > 3 ? null : "TOO_SHORT"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator],
debounce: 100,
}),
validate({
field: Schema.string,
rules: () => [stringLengthValidator],
debounce: 100,
}),
]);

const getValue = () => "ab" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue })
).runPromise()
).toEqual([[], [], [{ field: Schema.string, error: "TOO_SHORT" }]]);

expect(stringRequiredValidator).toHaveBeenCalledTimes(1);
expect(stringLengthValidator).toHaveBeenCalledTimes(1);
});

it("should properly debounce multiple debounced and non-debounced validators for the same field", async () => {
const stringRequiredValidator = jest.fn((x: string) =>
x ? null : "REQUIRED"
);
const stringLengthValidator = jest.fn((x: string) =>
x.length > 3 ? null : "TOO_SHORT"
);
const stringValueValidator = jest.fn((x: string) =>
x === "value" ? null : "INVALID_VALUE"
);

const { validate } = createFormValidatorImpl(Schema, validate => [
validate({
field: Schema.string,
rules: () => [stringRequiredValidator],
debounce: 100,
}),
validate(Schema.string, stringValueValidator),
validate({
field: Schema.string,
rules: () => [stringLengthValidator],
debounce: 100,
}),
]);

const getValue = () => "not-value" as any;

expect(
await Task.all(
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue }),
validate({ fields: [Schema.string], getValue })
).runPromise()
).toEqual([[], [], [{ field: Schema.string, error: "INVALID_VALUE" }]]);

expect(stringRequiredValidator).toHaveBeenCalledTimes(1);
expect(stringValueValidator).toHaveBeenCalledTimes(1);
expect(stringLengthValidator).toHaveBeenCalledTimes(0);
});
});
88 changes: 71 additions & 17 deletions src/core/builders/create-form-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ export const createFormValidator = <Values extends object, Err>(
validate: ValidateFn
) => Array<FieldValidator<any, Err, any | unknown>>
): FormValidator<Values, Err> => {
const allValidators = builder(validate);
const allValidators = builder(validate());
const dependenciesDict = buildDependenciesDict(allValidators);

const ongoingValidationTimestamps: Record<
FieldValidationKey,
Timestamp | undefined
> = {};

const debounceStepHandler = DebouncedValidation.createDebounceStepHandler();

const getValidatorsForField = (
descriptor: FieldDescriptor<unknown, Err>,
trigger?: ValidationTrigger
Expand Down Expand Up @@ -121,7 +123,9 @@ export const createFormValidator = <Values extends object, Err>(
.flatMap(value =>
firstNonNullTaskResult(
validators.map(v =>
runValidationForField(v, value, getValue)
debounceStepHandler(v, field).flatMap(() =>
runValidationForField(v, value, getValue)
)
)
)
)
Expand All @@ -144,7 +148,8 @@ export const createFormValidator = <Values extends object, Err>(

// primitive cancellation of outdated validation results
return null;
});
})
.flatMapErr(DebouncedValidation.flatMapCancel);
})
)
)
Expand All @@ -155,20 +160,26 @@ export const createFormValidator = <Values extends object, Err>(
return opaque(formValidator);
};

const validate: ValidateFn = <T, Err, Deps extends any[]>(
x: ValidateConfig<T, Err, Deps> | ValidateField<T, Err>,
...rules: Array<Validator<T, Err>>
): FieldValidator<T, Err, Deps> => {
const config: ValidateConfig<T, Err, Deps> =
(x as any)["field"] != null
? { ...(x as ValidateConfig<T, Err, Deps>) }
: { field: x as ValidateField<T, Err>, rules: () => rules };

return {
field: config.field,
triggers: config.triggers,
validators: config.rules,
dependencies: config.dependencies,
const validate = (): ValidateFn => {
let index = 0;

return <T, Err, Deps extends any[]>(
x: ValidateConfig<T, Err, Deps> | ValidateField<T, Err>,
...rules: Array<Validator<T, Err>>
): FieldValidator<T, Err, Deps> => {
const config: ValidateConfig<T, Err, Deps> =
(x as any)["field"] != null
? { ...(x as ValidateConfig<T, Err, Deps>) }
: { field: x as ValidateField<T, Err>, rules: () => rules };

return {
id: (index++).toString(),
field: config.field,
triggers: config.triggers,
validators: config.rules,
dependencies: config.dependencies,
debounce: config.debounce,
};
};
};

Expand Down Expand Up @@ -308,3 +319,46 @@ type Timestamp = number;
namespace Timestamp {
export const make = () => Date.now();
}

namespace DebouncedValidation {
type Cancel = () => void;
type DebouncedValidationsDict = Record<FieldValidationKey, Cancel>;
type DebounceStepHandler = (
validator: FieldValidator<unknown, unknown, unknown[]>,
field: FieldDescriptor<unknown, unknown>
) => Task<void>;

const CANCEL_ERR = "__CANCEL__";

export const createDebounceStepHandler = (): DebounceStepHandler => {
const debouncedValidations: DebouncedValidationsDict = {};

return (v, field) =>
Task.make(({ resolve, reject }) => {
const id = `${v.id}-${impl(field).__path}`;
if (v.debounce) {
debouncedValidations[id]?.();

const timeout = setTimeout(() => {
delete debouncedValidations[id];
resolve();
}, v.debounce);

debouncedValidations[id] = () => {
clearTimeout(timeout);
reject(CANCEL_ERR);
};
} else {
resolve();
}
});
};

export const flatMapCancel = (err: unknown) => {
if (err === CANCEL_ERR) {
return Task.success(null);
} else {
return Task.failure(err);
}
};
}
3 changes: 3 additions & 0 deletions src/core/types/form-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ export type _FormValidatorImpl<Values extends object, Err> = {
};

export type FieldValidator<T, Err, Dependencies extends any[]> = {
id: string;
field: ValidateField<T, Err>;
triggers?: Array<ValidationTrigger>;
validators: (...deps: [...Dependencies]) => Array<Falsy | Validator<T, Err>>;
dependencies?: readonly [...FieldDescTuple<Dependencies>];
debounce?: number;
};

export type ValidateFn = {
Expand All @@ -75,6 +77,7 @@ export type ValidateConfig<T, Err, Dependencies extends any[]> = {
field: ValidateField<T, Err>;
triggers?: ValidationTrigger[];
dependencies?: readonly [...FieldDescTuple<Dependencies>];
debounce?: number;
rules: (
...deps: [...Dependencies]
) => Array<Falsy | Validator<T, NoInfer<Err>>>;
Expand Down

0 comments on commit 3a04328

Please sign in to comment.