diff --git a/src/core/builders/create-form-validator.spec.ts b/src/core/builders/create-form-validator.spec.ts index d62df7d..e0f08f7 100644 --- a/src/core/builders/create-form-validator.spec.ts +++ b/src/core/builders/create-form-validator.spec.ts @@ -73,7 +73,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "" as any; - const validation = await validate([Schema.string], getValue); + const validation = await validate({ fields: [Schema.string], getValue }); expect(validation).toEqual([{ field: Schema.string, error: "REQUIRED" }]); }); @@ -88,7 +88,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "defined string" as any; - const validation = await validate([Schema.string], getValue); + const validation = await validate({ fields: [Schema.string], getValue }); expect(validation).toEqual([{ field: Schema.string, error: null }]); }); @@ -106,7 +106,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "" as any; - const validation = await validate([Schema.string], getValue); + const validation = await validate({ fields: [Schema.string], getValue }); expect(validation).toEqual([{ field: Schema.string, error: "REQUIRED" }]); }); @@ -124,7 +124,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "ab" as any; - const validation = await validate([Schema.string], getValue); + const validation = await validate({ fields: [Schema.string], getValue }); expect(validation).toEqual([{ field: Schema.string, error: "TOO_SHORT" }]); }); @@ -142,7 +142,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "abcd" as any; - const validation = await validate([Schema.string], getValue); + const validation = await validate({ fields: [Schema.string], getValue }); expect(validation).toEqual([{ field: Schema.string, error: null }]); }); @@ -156,7 +156,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "A" as any; - const validation = await validate([Schema.choice], getValue); + const validation = await validate({ fields: [Schema.choice], getValue }); expect(validation).toEqual([ { field: Schema.choice, error: "INVALID_VALUE" }, @@ -172,7 +172,7 @@ describe("createFormValidator", () => { ]); const getValue = () => "C" as any; - const validation = await validate([Schema.choice], getValue); + const validation = await validate({ fields: [Schema.choice], getValue }); expect(validation).toEqual([{ field: Schema.choice, error: null }]); }); @@ -186,7 +186,7 @@ describe("createFormValidator", () => { ]); const getValue = () => null as any; - const validation = await validate([Schema.instance], getValue); + const validation = await validate({ fields: [Schema.instance], getValue }); expect(validation).toEqual([{ field: Schema.instance, error: "REQUIRED" }]); }); @@ -203,7 +203,10 @@ describe("createFormValidator", () => { ]); const getValue = () => ["ok", "very-ok", "invalid", "still-ok"] as any; - const validation = await validate([Schema.arrayString], getValue); + const validation = await validate({ + fields: [Schema.arrayString], + getValue, + }); expect(validation).toEqual([ { field: Schema.arrayString, error: "INVALID_VALUE" }, @@ -220,7 +223,10 @@ describe("createFormValidator", () => { const getValue = () => [["ok"], ["very-ok"], ["invalid"], ["still-ok"]] as any; - const validation = await validate([Schema.arrayArrayString], getValue); + const validation = await validate({ + fields: [Schema.arrayArrayString], + getValue, + }); expect(validation).toEqual([ { field: Schema.arrayArrayString, error: null }, @@ -236,7 +242,7 @@ describe("createFormValidator", () => { ]); const getValue = () => null as any; - const validation = await validate([Schema.object], getValue); + const validation = await validate({ fields: [Schema.object], getValue }); expect(validation).toEqual([ { field: Schema.object, error: "INVALID_VALUE" }, @@ -252,7 +258,7 @@ describe("createFormValidator", () => { ]); const getValue = () => null as any; - const validation = await validate([Schema.object], getValue); + const validation = await validate({ fields: [Schema.object], getValue }); expect(validation).toEqual([{ field: Schema.object, error: "REQUIRED" }]); }); @@ -266,7 +272,7 @@ describe("createFormValidator", () => { ]); const getValue = () => null as any; - const validation = await validate([Schema.object], getValue); + const validation = await validate({ fields: [Schema.object], getValue }); expect(validation).toEqual([{ field: Schema.object, error: null }]); }); @@ -295,15 +301,15 @@ describe("createFormValidator", () => { } }; - const validation = await validate( - [ + const validation = await validate({ + fields: [ Schema.arrayObjectString.nth(0), Schema.arrayObjectString.nth(1), Schema.arrayObjectString.nth(2), Schema.arrayObjectString.nth(3), ], - getValue - ); + getValue, + }); expect(validation).toEqual([ { field: Schema.arrayObjectString.nth(0), error: "TOO_SHORT" }, @@ -338,15 +344,15 @@ describe("createFormValidator", () => { } }; - const validation = await validate( - [ + const validation = await validate({ + fields: [ Schema.arrayChoice.nth(1), Schema.arrayChoice.nth(0), Schema.arrayObjectString.nth(0), Schema.arrayObjectString.nth(1), ], - getValue - ); + getValue, + }); expect(validation).toEqual([ { field: Schema.arrayChoice.nth(1), error: null }, @@ -378,11 +384,11 @@ describe("createFormValidator", () => { } }; - const validation = await validate( - [Schema.string, Schema.choice], + const validation = await validate({ + fields: [Schema.string, Schema.choice], getValue, - "change" - ); + trigger: "change", + }); expect(validation).toEqual([{ field: Schema.choice, error: "REQUIRED" }]); }); @@ -443,11 +449,11 @@ describe("createFormValidator", () => { } }; - const validation = await validate( - [Schema.string, Schema.number, Schema.choice], + const validation = await validate({ + fields: [Schema.string, Schema.number, Schema.choice], getValue, - "change" - ); + trigger: "change", + }); expect(validation).toEqual([ { field: Schema.string, error: "TOO_SHORT" }, @@ -500,21 +506,103 @@ describe("createFormValidator", () => { } }; - const validation = await validate( - [ + const result = await validate({ + fields: [ Schema.arrayString.nth(0), Schema.arrayString.nth(1), Schema.arrayString.nth(2), Schema.string, ], - getValue - ); + getValue, + }); - expect(validation).toEqual([ + expect(result).toEqual([ { field: Schema.arrayString.nth(0), error: "INVALID_VALUE" }, { field: Schema.arrayString.nth(1), error: null }, { field: Schema.arrayString.nth(2), error: "TOO_SHORT" }, { field: Schema.string, error: "TOO_SHORT" }, ]); }); + + it("calls callback functions to signal start and end of validation for every affected field", async () => { + const pass = (_val: T) => wait(null); + + const { validate } = createFormValidator(Schema, validate => [ + validate({ + field: Schema.string, + rules: () => [pass], + }), + validate({ + field: Schema.string, + rules: () => [pass], + }), + validate.each({ + field: Schema.arrayString, + rules: () => [pass], + }), + validate({ + field: Schema.arrayString.nth(0), + rules: () => [pass], + }), + ]); + + const onFieldValidationStart = jest.fn(); + const onFieldValidationEnd = jest.fn(); + + const fields = [ + Schema.number, + Schema.arrayString.nth(0), + Schema.arrayString.nth(1), + Schema.arrayString.nth(2), + Schema.string, + ]; + + await validate({ + fields, + getValue: () => "foo" as any, + onFieldValidationStart, + onFieldValidationEnd, + }); + + const expectField = (desc: FieldDescriptor) => + expect.objectContaining({ __path: impl(desc).__path }); + + expect(onFieldValidationStart).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(0)) + ); + expect(onFieldValidationStart).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(1)) + ); + expect(onFieldValidationStart).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(2)) + ); + expect(onFieldValidationStart).toHaveBeenCalledWith( + expectField(Schema.string) + ); + expect(onFieldValidationStart).not.toHaveBeenCalledWith( + expectField(Schema.number) + ); + expect(onFieldValidationStart).not.toHaveBeenCalledWith( + expectField(Schema.arrayString) + ); + + expect(onFieldValidationEnd).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(0)) + ); + expect(onFieldValidationEnd).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(1)) + ); + expect(onFieldValidationEnd).toHaveBeenCalledWith( + expectField(Schema.arrayString.nth(2)) + ); + expect(onFieldValidationEnd).toHaveBeenCalledWith( + expectField(Schema.string) + ); + expect(onFieldValidationEnd).not.toHaveBeenCalledWith( + expectField(Schema.number) + ); + expect(onFieldValidationEnd).not.toHaveBeenCalledWith( + expectField(Schema.arrayString) + ); + }); }); diff --git a/src/core/builders/create-form-validator.ts b/src/core/builders/create-form-validator.ts index 0a3b587..f468fd0 100644 --- a/src/core/builders/create-form-validator.ts +++ b/src/core/builders/create-form-validator.ts @@ -63,7 +63,13 @@ export const createFormValidator = ( }; const formValidator: FormValidator = { - validate: (fields, getValue, trigger) => { + validate: ({ + fields, + trigger, + getValue, + onFieldValidationStart, + onFieldValidationEnd, + }) => { const fieldsToValidate = fields .map(field => ({ field, @@ -72,13 +78,16 @@ export const createFormValidator = ( .filter(x => x.validators.length > 0); return Promise.all( - fieldsToValidate.map(async ({ field, validators }) => { + fieldsToValidate.map(({ field, validators }) => { const value = getValue(field); - const error = await firstNonNullPromise(validators, x => - runValidationForField(x, value) - ); - return { field, error }; + onFieldValidationStart?.(field); + return firstNonNullPromise(validators, v => + runValidationForField(v, value) + ).then(error => { + onFieldValidationEnd?.(field); + return { field, error }; + }); }) ); }, @@ -103,7 +112,7 @@ validate.each = config => ({ dependencies: config.dependencies, }); -const runValidationForField = async ( +const runValidationForField = ( validator: FieldValidator, value: Value ): Promise => { @@ -111,20 +120,21 @@ const runValidationForField = async ( .validators([] as any) .filter(x => !isFalsy(x)) as Validator[]; - return firstNonNullPromise(rules, async rule => await rule(value)); + return firstNonNullPromise(rules, rule => Promise.resolve(rule(value))); }; -const firstNonNullPromise = async ( +const firstNonNullPromise = ( list: T[], - mapper: (x: T) => Promise + provider: (x: T) => Promise ): Promise => { - for (const x of list) { - const result = await mapper(x); - if (result != null) { - return result; - } + if (list.length === 0) { + return Promise.resolve(null); } - return null; + + const [el, ...rest] = list; + return provider(el).then(result => + result != null ? result : firstNonNullPromise(rest, provider) + ); }; // TODO rethink diff --git a/src/core/hooks/use-formts/reducer.ts b/src/core/hooks/use-formts/reducer.ts index ac3eb5f..b203baa 100644 --- a/src/core/hooks/use-formts/reducer.ts +++ b/src/core/hooks/use-formts/reducer.ts @@ -1,6 +1,6 @@ import { Reducer } from "react"; -import { get, set } from "../../../utils"; +import { filter, get, range, set } from "../../../utils"; import { FormtsState } from "../../types/formts-state"; import { createInitialValues } from "./create-initial-values"; @@ -12,7 +12,8 @@ export type FormtsAction = | { type: "touchValue"; payload: { path: string } } | { type: "setValue"; payload: { path: string; value: any } } | { type: "setErrors"; payload: Array<{ path: string; error: Err | null }> } - | { type: "setIsValidating"; payload: { isValidating: boolean } } + | { type: "validatingStart"; payload: { path: string; uuid: string } } + | { type: "validatingStop"; payload: { path: string; uuid: string } } | { type: "setIsSubmitting"; payload: { isSubmitting: boolean } }; export const createReducer = (): Reducer< @@ -28,7 +29,7 @@ export const createReducer = (): Reducer< values, touched, errors: {}, - isValidating: false, + validating: {}, isSubmitting: false, }; } @@ -45,10 +46,30 @@ export const createReducer = (): Reducer< case "setValue": { const { path, value } = action.payload; + const resolveErrors = () => { + if (!Array.isArray(value)) { + return state.errors; + } + + const currentValue = get(state.values, path) as unknown[]; + if (currentValue.length <= value.length) { + return state.errors; + } + + const hangingIndexes = range(value.length, currentValue.length - 1); + const errors = filter( + state.errors, + ({ key }) => + !hangingIndexes.some(i => key.startsWith(`${path}[${i}]`)) + ); + + return errors; + }; + const values = set(state.values, path, value); const touched = set(state.touched, path, makeTouchedValues(value)); - return { ...state, values, touched }; + return { ...state, values, touched, errors: resolveErrors() }; } case "setErrors": { @@ -67,10 +88,41 @@ export const createReducer = (): Reducer< return { ...state, errors }; } - case "setIsValidating": { - const { isValidating } = action.payload; - return { ...state, isValidating }; + case "validatingStart": { + const { path, uuid } = action.payload; + + const validating = { + ...state.validating, + [path]: { ...state.validating[path], [uuid]: true as const }, + }; + + return { ...state, validating }; } + + case "validatingStop": { + const { path, uuid } = action.payload; + + const validating = (() => { + if (state.validating[path] == null) { + return state.validating; + } + + const validating = { ...state.validating }; + const uuids = { ...validating[path] }; + validating[path] = uuids; + + delete uuids[uuid]; + + if (Object.keys(uuids).length === 0) { + delete validating[path]; + } + + return validating; + })(); + + return { ...state, validating }; + } + case "setIsSubmitting": { const { isSubmitting } = action.payload; return { ...state, isSubmitting }; @@ -89,7 +141,7 @@ export const getInitialState = ({ values, touched, errors: {}, - isValidating: false, + validating: {}, isSubmitting: false, }; }; diff --git a/src/core/hooks/use-formts/resolve-is-valid.spec.ts b/src/core/hooks/use-formts/resolve-is-valid.spec.ts index a96d636..ff71b92 100644 --- a/src/core/hooks/use-formts/resolve-is-valid.spec.ts +++ b/src/core/hooks/use-formts/resolve-is-valid.spec.ts @@ -1,7 +1,21 @@ +import { FieldDescriptor } from "../../types/field-descriptor"; import { FieldErrors } from "../../types/formts-state"; +import { opaque } from "../../types/type-mapper-util"; import { resolveIsValid } from "./resolve-is-valid"; +const primitiveDescriptor = (path: string): FieldDescriptor => + opaque({ + __path: path, + __decoder: { fieldType: "string" } as any, + }); + +const complexDescriptor = (path: string): FieldDescriptor => + opaque({ + __path: path, + __decoder: { fieldType: "object" } as any, + }); + describe("resolveIsValid", () => { it("handles primitive field errors", () => { const errors: FieldErrors = { @@ -9,9 +23,9 @@ describe("resolveIsValid", () => { bar: undefined, }; - expect(resolveIsValid(errors, "foo")).toBe(false); - expect(resolveIsValid(errors, "bar")).toBe(true); - expect(resolveIsValid(errors, "baz")).toBe(true); + expect(resolveIsValid(errors, primitiveDescriptor("foo"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("bar"))).toBe(true); + expect(resolveIsValid(errors, primitiveDescriptor("baz"))).toBe(true); }); it("handles root array field errors", () => { @@ -19,8 +33,8 @@ describe("resolveIsValid", () => { array: "error!", }; - expect(resolveIsValid(errors, "array")).toBe(false); - expect(resolveIsValid(errors, "array[42]")).toBe(true); + expect(resolveIsValid(errors, complexDescriptor("array"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("array[42]"))).toBe(true); }); it("handles array item field errors", () => { @@ -28,9 +42,10 @@ describe("resolveIsValid", () => { "array[0]": "error!", }; - expect(resolveIsValid(errors, "array")).toBe(false); - expect(resolveIsValid(errors, "array[0]")).toBe(false); - expect(resolveIsValid(errors, "array[42]")).toBe(true); + expect(resolveIsValid(errors, primitiveDescriptor("array"))).toBe(true); + expect(resolveIsValid(errors, complexDescriptor("array"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("array[0]"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("array[42]"))).toBe(true); }); it("handles root object field errors", () => { @@ -38,8 +53,10 @@ describe("resolveIsValid", () => { object: "error!", }; - expect(resolveIsValid(errors, "object")).toBe(false); - expect(resolveIsValid(errors, "object.prop")).toBe(true); + expect(resolveIsValid(errors, complexDescriptor("object"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("object.prop"))).toBe( + true + ); }); it("handles object property field errors", () => { @@ -47,9 +64,14 @@ describe("resolveIsValid", () => { "object.prop": "error!", }; - expect(resolveIsValid(errors, "object")).toBe(false); - expect(resolveIsValid(errors, "object.prop")).toBe(false); - expect(resolveIsValid(errors, "object.otherProp")).toBe(true); + expect(resolveIsValid(errors, primitiveDescriptor("object"))).toBe(true); + expect(resolveIsValid(errors, complexDescriptor("object"))).toBe(false); + expect(resolveIsValid(errors, primitiveDescriptor("object.prop"))).toBe( + false + ); + expect( + resolveIsValid(errors, primitiveDescriptor("object.otherProp")) + ).toBe(true); }); it("handles nested object and array fields", () => { @@ -57,13 +79,33 @@ describe("resolveIsValid", () => { "nested.nestedArr[42].foo": "error!", }; - expect(resolveIsValid(errors, "nested")).toBe(false); - expect(resolveIsValid(errors, "nested.nestedArr")).toBe(false); - expect(resolveIsValid(errors, "nested.nestedArr[42]")).toBe(false); - expect(resolveIsValid(errors, "nested.nestedArr[42].foo")).toBe(false); + expect(resolveIsValid(errors, complexDescriptor("nested"))).toBe(false); + + expect(resolveIsValid(errors, complexDescriptor("nested.nestedArr"))).toBe( + false + ); + + expect( + resolveIsValid(errors, complexDescriptor("nested.nestedArr[42]")) + ).toBe(false); + + expect( + resolveIsValid(errors, primitiveDescriptor("nested.nestedArr[42].foo")) + ).toBe(false); + + expect( + resolveIsValid(errors, primitiveDescriptor("nested.otherProp")) + ).toBe(true); + + expect( + resolveIsValid(errors, complexDescriptor("nested.nestedArr[43]")) + ).toBe(true); - expect(resolveIsValid(errors, "nested.otherProp")).toBe(true); - expect(resolveIsValid(errors, "nested.nestedArr[43]")).toBe(true); - expect(resolveIsValid(errors, "nested.nestedArr[42].otherProp")).toBe(true); + expect( + resolveIsValid( + errors, + complexDescriptor("nested.nestedArr[42].otherProp") + ) + ).toBe(true); }); }); diff --git a/src/core/hooks/use-formts/resolve-is-valid.ts b/src/core/hooks/use-formts/resolve-is-valid.ts index ab089d4..c59c6ce 100644 --- a/src/core/hooks/use-formts/resolve-is-valid.ts +++ b/src/core/hooks/use-formts/resolve-is-valid.ts @@ -1,14 +1,25 @@ import { entries } from "../../../utils"; +import { + FieldDescriptor, + isPrimitiveDescriptor, +} from "../../types/field-descriptor"; import { FieldErrors } from "../../types/formts-state"; +import { impl } from "../../types/type-mapper-util"; export const resolveIsValid = ( errors: FieldErrors, - path: string + field: FieldDescriptor ): boolean => { + const path = impl(field).__path; + if (errors[path] != null) { return false; } + if (isPrimitiveDescriptor(field)) { + return errors[path] == null; + } + return not( entries(errors).some( ([errorPath, error]) => error != null && errorPath.startsWith(path) diff --git a/src/core/hooks/use-formts/resolve-is-validating.spec.ts b/src/core/hooks/use-formts/resolve-is-validating.spec.ts new file mode 100644 index 0000000..37214a3 --- /dev/null +++ b/src/core/hooks/use-formts/resolve-is-validating.spec.ts @@ -0,0 +1,123 @@ +import { FieldDescriptor } from "../../types/field-descriptor"; +import { FieldValidatingState } from "../../types/formts-state"; +import { opaque } from "../../types/type-mapper-util"; + +import { resolveIsValidating } from "./resolve-is-validating"; + +const primitiveDescriptor = (path: string): FieldDescriptor => + opaque({ + __path: path, + __decoder: { fieldType: "string" } as any, + }); + +const complexDescriptor = (path: string): FieldDescriptor => + opaque({ + __path: path, + __decoder: { fieldType: "object" } as any, + }); + +describe("resolveIsValidating", () => { + it("handles primitive fields", () => { + const state: FieldValidatingState = { + foo: { aaa: true }, + }; + + expect(resolveIsValidating(state, primitiveDescriptor("foo"))).toBe(true); + expect(resolveIsValidating(state, primitiveDescriptor("bar"))).toBe(false); + expect(resolveIsValidating(state, primitiveDescriptor("baz"))).toBe(false); + }); + + it("handles root array fields", () => { + const state: FieldValidatingState = { + array: { aaa: true }, + }; + + expect(resolveIsValidating(state, complexDescriptor("array"))).toBe(true); + expect(resolveIsValidating(state, primitiveDescriptor("array[42]"))).toBe( + false + ); + }); + + it("handles array item field", () => { + const state: FieldValidatingState = { + "array[0]": { aaa: true }, + }; + + expect(resolveIsValidating(state, primitiveDescriptor("array"))).toBe( + false + ); + expect(resolveIsValidating(state, complexDescriptor("array"))).toBe(true); + expect(resolveIsValidating(state, primitiveDescriptor("array[0]"))).toBe( + true + ); + expect(resolveIsValidating(state, primitiveDescriptor("array[42]"))).toBe( + false + ); + }); + + it("handles root object field", () => { + const state: FieldValidatingState = { + object: { aaa: true }, + }; + + expect(resolveIsValidating(state, complexDescriptor("object"))).toBe(true); + expect(resolveIsValidating(state, primitiveDescriptor("object.prop"))).toBe( + false + ); + }); + + it("handles object property field errors", () => { + const state: FieldValidatingState = { + "object.prop": { aaa: true }, + }; + + expect(resolveIsValidating(state, primitiveDescriptor("object"))).toBe( + false + ); + expect(resolveIsValidating(state, complexDescriptor("object"))).toBe(true); + expect(resolveIsValidating(state, primitiveDescriptor("object.prop"))).toBe( + true + ); + expect( + resolveIsValidating(state, primitiveDescriptor("object.otherProp")) + ).toBe(false); + }); + + it("handles nested object and array fields", () => { + const state: FieldValidatingState = { + "nested.nestedArr[42].foo": { aaa: true }, + }; + + expect(resolveIsValidating(state, complexDescriptor("nested"))).toBe(true); + + expect( + resolveIsValidating(state, complexDescriptor("nested.nestedArr")) + ).toBe(true); + + expect( + resolveIsValidating(state, complexDescriptor("nested.nestedArr[42]")) + ).toBe(true); + + expect( + resolveIsValidating( + state, + primitiveDescriptor("nested.nestedArr[42].foo") + ) + ).toBe(true); + + expect( + resolveIsValidating(state, primitiveDescriptor("nested.otherProp")) + ).toBe(false); + + expect( + resolveIsValidating(state, complexDescriptor("nested.nestedArr[43]")) + ).toBe(false); + + expect( + resolveIsValidating( + state, + complexDescriptor("nested.nestedArr[42].otherProp") + ) + ).toBe(false); + }); +}); diff --git a/src/core/hooks/use-formts/resolve-is-validating.ts b/src/core/hooks/use-formts/resolve-is-validating.ts new file mode 100644 index 0000000..36c217b --- /dev/null +++ b/src/core/hooks/use-formts/resolve-is-validating.ts @@ -0,0 +1,27 @@ +import { entries } from "../../../utils"; +import { + FieldDescriptor, + isPrimitiveDescriptor, +} from "../../types/field-descriptor"; +import { FieldValidatingState } from "../../types/formts-state"; +import { impl } from "../../types/type-mapper-util"; + +export const resolveIsValidating = ( + validatingState: FieldValidatingState, + field: FieldDescriptor +): boolean => { + const path = impl(field).__path; + + if (validatingState[path] != null) { + return true; + } + + if (isPrimitiveDescriptor(field)) { + return validatingState[path] != null; + } + + return entries(validatingState).some( + ([validatingFieldPath, validations]) => + validations != null && validatingFieldPath.startsWith(path) + ); +}; diff --git a/src/core/hooks/use-formts/use-formts.spec.ts b/src/core/hooks/use-formts/use-formts.spec.ts index 831fd3f..949b358 100644 --- a/src/core/hooks/use-formts/use-formts.spec.ts +++ b/src/core/hooks/use-formts/use-formts.spec.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react-hooks"; import { createFormSchema } from "../../builders/create-form-schema"; +import { ValidateIn } from "../../types/form-validator"; import { useFormts } from "./use-formts"; @@ -290,6 +291,46 @@ describe("useFormts", () => { } }); + it("clears errors corresponding to removed array values", () => { + const hook = renderHook(() => useFormts({ Schema })); + + { + const [, form] = hook.result.current; + expect(form.errors).toEqual([]); + } + + act(() => { + const [fields] = hook.result.current; + fields.theArray.setValue(["A", "B", "C"]); + }); + + act(() => { + const [fields] = hook.result.current; + fields.theArray.children[0].setError("ERR"); + fields.theArray.children[1].setError("ERR"); + fields.theArray.children[2].setError("ERR"); + }); + + { + const [, form] = hook.result.current; + expect(form.errors).toEqual([ + { path: "theArray[0]", error: "ERR" }, + { path: "theArray[1]", error: "ERR" }, + { path: "theArray[2]", error: "ERR" }, + ]); + } + + act(() => { + const [fields] = hook.result.current; + fields.theArray.setValue(["AAA"]); + }); + + { + const [, form] = hook.result.current; + expect(form.errors).toEqual([{ path: "theArray[0]", error: "ERR" }]); + } + }); + it("exposes options of choice fields", () => { const hook = renderHook(() => useFormts({ Schema })); @@ -465,14 +506,16 @@ describe("useFormts", () => { it("validates fields when field value is changed", async () => { const validator = { - validate: jest.fn().mockImplementation((fields: any[], getValue: any) => - Promise.resolve( - fields.map(field => ({ - field, - error: getValue(field) === "" ? "REQUIRED" : null, - })) - ) - ), + validate: jest + .fn() + .mockImplementation(({ fields, getValue }: ValidateIn) => + Promise.resolve( + fields.map(field => ({ + field, + error: getValue(field) === "" ? "REQUIRED" : null, + })) + ) + ), }; const hook = renderHook(() => useFormts({ Schema, validator })); @@ -525,7 +568,7 @@ describe("useFormts", () => { const validator = { validate: jest .fn() - .mockImplementation((fields: any[]) => + .mockImplementation(({ fields }: ValidateIn) => Promise.resolve(fields.map(field => ({ field, error: "ERROR" }))) ), }; @@ -565,7 +608,7 @@ describe("useFormts", () => { const validator = { validate: jest .fn() - .mockImplementationOnce((fields: any[]) => + .mockImplementationOnce(({ fields }: ValidateIn) => Promise.resolve(fields.map(field => ({ field, error: "ERROR" }))) ) .mockResolvedValueOnce([]), diff --git a/src/core/hooks/use-formts/use-formts.ts b/src/core/hooks/use-formts/use-formts.ts index 4d2ed7d..1614c46 100644 --- a/src/core/hooks/use-formts/use-formts.ts +++ b/src/core/hooks/use-formts/use-formts.ts @@ -29,6 +29,7 @@ import { impl } from "../../types/type-mapper-util"; import { createInitialValues } from "./create-initial-values"; import { createReducer, getInitialState } from "./reducer"; import { resolveIsValid } from "./resolve-is-valid"; +import { resolveIsValidating } from "./resolve-is-validating"; import { resolveTouched } from "./resolve-touched"; export type FormtsOptions = { @@ -74,7 +75,28 @@ export const useFormts = ( resolveTouched(get(state.touched as object, impl(field).__path)); const isFieldValid = (field: FieldDescriptor) => - resolveIsValid(state.errors, impl(field).__path); + resolveIsValid(state.errors, field); + + const isFieldValidating = (field: FieldDescriptor) => + resolveIsValidating(state.validating, field); + + const makeValidationHandlers = () => { + const uuid = new Date().valueOf().toString(); + return { + onFieldValidationStart: (field: FieldDescriptor) => { + dispatch({ + type: "validatingStart", + payload: { path: impl(field).__path, uuid }, + }); + }, + onFieldValidationEnd: (field: FieldDescriptor) => { + dispatch({ + type: "validatingStop", + payload: { path: impl(field).__path, uuid }, + }); + }, + }; + }; const validateField = ( field: FieldDescriptor, @@ -83,13 +105,21 @@ export const useFormts = ( if (!options.validator) { return Promise.resolve(); } + const { + onFieldValidationStart, + onFieldValidationEnd, + } = makeValidationHandlers(); - dispatch({ type: "setIsValidating", payload: { isValidating: true } }); return options.validator - .validate([field], getField, trigger) + .validate({ + fields: [field], + getValue: getField, + trigger, + onFieldValidationStart, + onFieldValidationEnd, + }) .then(errors => { setFieldErrors(...errors); - dispatch({ type: "setIsValidating", payload: { isValidating: false } }); }); }; @@ -98,13 +128,20 @@ export const useFormts = ( return Promise.resolve([]); } const topLevelDescriptors = values(fields).map(it => it.descriptor); + const { + onFieldValidationStart, + onFieldValidationEnd, + } = makeValidationHandlers(); - dispatch({ type: "setIsValidating", payload: { isValidating: true } }); return options.validator - .validate(topLevelDescriptors, getField) + .validate({ + fields: topLevelDescriptors, + getValue: getField, + onFieldValidationStart, + onFieldValidationEnd, + }) .then(errors => { setFieldErrors(...errors); - dispatch({ type: "setIsValidating", payload: { isValidating: false } }); return errors; }); }; @@ -138,16 +175,21 @@ export const useFormts = ( return getField(fieldToValidate); }; - dispatch({ type: "setIsValidating", payload: { isValidating: true } }); + const { + onFieldValidationStart, + onFieldValidationEnd, + } = makeValidationHandlers(); return options.validator - .validate([field], modifiedGetField, "change") + .validate({ + fields: [field], + getValue: modifiedGetField, + trigger: "change", + onFieldValidationStart, + onFieldValidationEnd, + }) .then(errors => { setFieldErrors(...errors); - dispatch({ - type: "setIsValidating", - payload: { isValidating: false }, - }); }); }; @@ -200,6 +242,10 @@ export const useFormts = ( return isFieldValid(descriptor); }, + get isValidating() { + return isFieldValidating(descriptor); + }, + get children() { if (isObjectDescriptor(descriptor)) { return keys(descriptor).reduce( @@ -266,8 +312,6 @@ export const useFormts = ( isSubmitting: state.isSubmitting, - isValidating: state.isValidating, - get errors() { return entries(state.errors) .filter(([, err]) => err != null) @@ -282,6 +326,10 @@ export const useFormts = ( return values(state.errors).filter(err => err != null).length === 0; }, + get isValidating() { + return keys(state.validating).length > 0; + }, + validate: () => { return validateForm(); }, diff --git a/src/core/types/field-descriptor.ts b/src/core/types/field-descriptor.ts index d1ca1ef..afb8dee 100644 --- a/src/core/types/field-descriptor.ts +++ b/src/core/types/field-descriptor.ts @@ -44,3 +44,9 @@ export const isObjectDescriptor = ( it: FieldDescriptor ): it is ObjectFieldDescriptor => impl(it).__decoder.fieldType === "object"; + +export const isPrimitiveDescriptor = ( + field: FieldDescriptor +): boolean => { + return !isArrayDescriptor(field) && !isObjectDescriptor(field); +}; diff --git a/src/core/types/field-handle.ts b/src/core/types/field-handle.ts index f14eddd..8d00e95 100644 --- a/src/core/types/field-handle.ts +++ b/src/core/types/field-handle.ts @@ -41,7 +41,7 @@ type BaseFieldHandle = { isValid: boolean; /** True if validation process of the field is ongoing */ - // isValidating: boolean; // TODO: post-mvp + isValidating: boolean; /** FieldDescriptor corresponding to the field */ descriptor: FieldDescriptor; diff --git a/src/core/types/form-validator.ts b/src/core/types/form-validator.ts index 58c35e7..e95c130 100644 --- a/src/core/types/form-validator.ts +++ b/src/core/types/form-validator.ts @@ -26,13 +26,17 @@ export type ValidationResult = Array<{ error: Err | null; }>; +export type ValidateIn = { + fields: Array>; + trigger?: ValidationTrigger; + getValue:

(field: FieldDescriptor) => P; + onFieldValidationStart?: (field: FieldDescriptor) => void; + onFieldValidationEnd?: (field: FieldDescriptor) => void; +}; + // @ts-ignore export type FormValidator = { - validate: ( - fields: Array>, - getValue:

(field: FieldDescriptor) => P, - trigger?: ValidationTrigger - ) => Promise>; + validate: (input: ValidateIn) => Promise>; }; export type FieldValidator = { diff --git a/src/core/types/formts-state.ts b/src/core/types/formts-state.ts index 2112b11..fe017f6 100644 --- a/src/core/types/formts-state.ts +++ b/src/core/types/formts-state.ts @@ -3,7 +3,7 @@ export type FormtsState = { values: Values; touched: TouchedValues; errors: FieldErrors; - isValidating: boolean; + validating: FieldValidatingState; isSubmitting: boolean; }; @@ -13,4 +13,9 @@ export type TouchedValues = [V] extends [Array] ? { [P in keyof V]: TouchedValues } : boolean; -export type FieldErrors = Record; +type FieldPath = string; +type Uuid = string; + +export type FieldErrors = Record; + +export type FieldValidatingState = Record>; diff --git a/src/utils/misc-utils.spec.ts b/src/utils/misc-utils.spec.ts new file mode 100644 index 0000000..da07a17 --- /dev/null +++ b/src/utils/misc-utils.spec.ts @@ -0,0 +1,16 @@ +import { range } from "./misc-utils"; + +describe("range", () => { + [ + { start: 0, end: 5, result: [0, 1, 2, 3, 4, 5] }, + { start: 5, end: 0, result: [5, 4, 3, 2, 1, 0] }, + { start: -1, end: 1, result: [-1, 0, 1] }, + { start: 0, end: 0, result: [0] }, + { start: 10, end: 10, result: [10] }, + { start: -10, end: -10, result: [-10] }, + ].forEach(({ start, end, result }) => + it(`range(${start}, ${end}) == ${result}`, () => { + expect(range(start, end)).toEqual(result); + }) + ); +}); diff --git a/src/utils/misc-utils.ts b/src/utils/misc-utils.ts index 8822b5c..344137e 100644 --- a/src/utils/misc-utils.ts +++ b/src/utils/misc-utils.ts @@ -1,3 +1,17 @@ export const assertNever = (_it: never): never => { throw new Error("Illegal state"); }; + +/** + * get array of consecutive integers in given range (inclusive) + */ +export const range = (start: number, end: number): number[] => { + const step = end > start ? 1 : -1; + const result = [start]; + + for (let i = start; i !== end; i = i + step) { + result.push(i + step); + } + + return result; +}; diff --git a/src/utils/object.spec.ts b/src/utils/object.spec.ts index bb80e2b..f226ccb 100644 --- a/src/utils/object.spec.ts +++ b/src/utils/object.spec.ts @@ -1,4 +1,24 @@ -import { deepMerge, get, set } from "./object"; +import { deepMerge, get, set, filter } from "./object"; + +describe("filter", () => { + it("returns object with filtered out properties", () => { + const object = { + a: 1, + b: 2, + c: 3, + d: 4, + x: 42, + }; + + expect( + filter(object, ({ key, value }) => value < 3 || key === "x") + ).toEqual({ + a: 1, + b: 2, + x: 42, + }); + }); +}); describe("get", () => { it("empty path should return origin ", () => { diff --git a/src/utils/object.ts b/src/utils/object.ts index 5c643a9..0aca200 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -9,6 +9,17 @@ export const keys = (o: T): (keyof T)[] => export const values = (o: T): T[keyof T][] => Object.values(o) as T[keyof T][]; +export const filter = ( + obj: Record, + predicate: (input: { key: string; value: T }) => boolean +): Record => + entries(obj).reduce((acc, [key, value]) => { + if (predicate({ key, value })) { + acc[key] = value; + } + return acc; + }, {} as Record); + type JsPropertyDescriptor = { configurable?: boolean; enumerable?: boolean;