From 8eaab1ffdde52ba38bfe8d3adb311cec1365122d Mon Sep 17 00:00:00 2001 From: pidkopajo <61269871+pidkopajo@users.noreply.github.com> Date: Fri, 19 Mar 2021 08:58:11 +0100 Subject: [PATCH] fix(core): child validation triggers parent validation (#84) --- src/core/builders/create-form-schema.ts | 21 +++- .../builders/create-form-validator.spec.ts | 111 ++++++++++++++++++ src/core/builders/create-form-validator.ts | 29 +++-- src/core/types/field-descriptor.ts | 1 + 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/src/core/builders/create-form-schema.ts b/src/core/builders/create-form-schema.ts index 709be62..d1ec95d 100644 --- a/src/core/builders/create-form-schema.ts +++ b/src/core/builders/create-form-schema.ts @@ -40,14 +40,16 @@ export const createFormSchema = ( const createObjectSchema = ( decodersMap: DecodersMap, lens: Lens, - path?: string + path?: string, + parent?: _FieldDescriptorImpl ) => { return keys(decodersMap).reduce((schema, key) => { const decoder = decodersMap[key]; (schema as any)[key] = createFieldDescriptor( impl(decoder) as _FieldDecoderImpl, Lens.compose(lens, Lens.prop(key as any)), - path ? `${path}.${key}` : `${key}` + path ? `${path}.${key}` : `${key}`, + parent ); return schema; }, {} as FormSchema); @@ -56,7 +58,8 @@ const createObjectSchema = ( const createFieldDescriptor = ( decoder: _FieldDecoderImpl, lens: Lens, - path: string + path: string, + parent?: _FieldDescriptorImpl ): _FieldDescriptorImpl => { // these properties are hidden implementation details and thus should not be enumerable const rootDescriptor = defineProperties( @@ -80,6 +83,12 @@ const createFieldDescriptor = ( writable: false, configurable: false, }, + __parent: { + value: parent, + enumerable: false, + writable: false, + configurable: false, + }, } ); @@ -96,7 +105,8 @@ const createFieldDescriptor = ( createFieldDescriptor( decoder.inner as _FieldDecoderImpl, Lens.compose(lens, Lens.index(i)), - `${path}[${i}]` + `${path}[${i}]`, + rootDescriptor ); const nth = defineProperties(nthHandler, { @@ -115,7 +125,8 @@ const createFieldDescriptor = ( const props = createObjectSchema( decoder.inner as DecodersMap, lens, - path + path, + rootDescriptor ); return Object.assign(rootDescriptor, props); } diff --git a/src/core/builders/create-form-validator.spec.ts b/src/core/builders/create-form-validator.spec.ts index 6792d7e..55fc24e 100644 --- a/src/core/builders/create-form-validator.spec.ts +++ b/src/core/builders/create-form-validator.spec.ts @@ -61,6 +61,10 @@ describe("createFormValidator", () => { array: fields.array(fields.object({ str: fields.string() })), }), }), + objectTwoArrays: fields.object({ + arrayString: fields.array(fields.string()), + arrayNumber: fields.array(fields.number()), + }), }), error => error<"REQUIRED" | "TOO_SHORT" | "INVALID_VALUE">() ); @@ -1095,6 +1099,113 @@ describe("createFormValidator", () => { expect(validation).toEqual([{ field: Schema.string, error: null }]); }); + + it("should trigger parent validation when child is validating", async () => { + const { validate } = createFormValidatorImpl(Schema, validate => [ + validate({ + field: Schema.objectArray.arrayString.nth, + rules: () => [x => (x === "" ? "INVALID_VALUE" : null)], + }), + validate({ + field: Schema.objectArray.arrayString, + rules: () => [x => (x.length < 2 ? "TOO_SHORT" : null)], + }), + validate({ + field: Schema.objectArray, + rules: () => [x => (x.arrayString.length < 2 ? "TOO_SHORT" : null)], + }), + ]); + + const getValue = (field: FieldDescriptor | string): any => { + const path = typeof field === "string" ? field : impl(field).__path; + switch (path) { + case "objectArray": + return { arrayString: [""] }; + case "objectArray.arrayString": + return [""]; + case "objectArray.arrayString[0]": + return ""; + } + }; + + const validation = await validate({ + fields: [Schema.objectArray.arrayString.nth(0)], + getValue, + }).runPromise(); + + expect(validation).toEqual([ + { field: Schema.objectArray.arrayString.nth(0), error: "INVALID_VALUE" }, + { field: Schema.objectArray.arrayString, error: "TOO_SHORT" }, + { field: Schema.objectArray, error: "TOO_SHORT" }, + ]); + }); + + it("should trigger parent validation when child is validating and not trigger another parents branches", async () => { + const stringValidator = jest.fn((x: string) => + x === "" ? "INVALID_VALUE" : null + ); + const numberValidator = jest.fn((x: number | "") => + x === "" ? "INVALID_VALUE" : null + ); + + const arrayNumberValidator = jest.fn((_: number[]) => null); + + const { validate } = createFormValidatorImpl(Schema, validate => [ + validate({ + field: Schema.objectTwoArrays.arrayString.nth, + rules: () => [stringValidator], + }), + validate({ + field: Schema.objectTwoArrays.arrayNumber.nth, + rules: () => [numberValidator], + }), + validate({ + field: Schema.objectTwoArrays.arrayString, + rules: () => [x => (x.length < 2 ? "TOO_SHORT" : null)], + }), + validate({ + field: Schema.objectTwoArrays.arrayNumber, + rules: () => [arrayNumberValidator], + }), + validate({ + field: Schema.objectTwoArrays, + rules: () => [x => (x.arrayString.length < 2 ? "TOO_SHORT" : null)], + }), + ]); + + const getValue = (field: FieldDescriptor | string): any => { + const path = typeof field === "string" ? field : impl(field).__path; + switch (path) { + case "objectTwoArrays": + return { arrayString: [""], arrayNumber: [1] }; + case "objectTwoArrays.arrayString": + return [""]; + case "objectTwoArrays.arrayNumber": + return [1]; + case "objectTwoArrays.arrayString[0]": + return ""; + case "objectTwoArrays.arrayNumber[0]": + return 1; + } + }; + + const validation = await validate({ + fields: [Schema.objectTwoArrays.arrayString.nth(0)], + getValue, + }).runPromise(); + + expect(validation).toEqual([ + { + field: Schema.objectTwoArrays.arrayString.nth(0), + error: "INVALID_VALUE", + }, + { field: Schema.objectTwoArrays.arrayString, error: "TOO_SHORT" }, + { field: Schema.objectTwoArrays, error: "TOO_SHORT" }, + ]); + expect(stringValidator).toBeCalledTimes(1); + expect(numberValidator).not.toHaveBeenCalled(); + expect(arrayNumberValidator).not.toHaveBeenCalled(); + }); }); describe("debounced validation", () => { diff --git a/src/core/builders/create-form-validator.ts b/src/core/builders/create-form-validator.ts index 2bbc1ab..9d2dbd5 100644 --- a/src/core/builders/create-form-validator.ts +++ b/src/core/builders/create-form-validator.ts @@ -90,8 +90,10 @@ export const createFormValidator = ( const dependents = flatMap(fields, x => getDependents(x, dependenciesDict, getValue) ); + const parents = flatMap(fields, getParentsChain); + const uniqueFields = uniqBy( - [...allFields, ...dependents], + [...allFields, ...dependents, ...parents], x => impl(x).__path ); @@ -273,6 +275,18 @@ const getDependents = ( } }); +const getParentsChain = ( + desc: FieldDescriptor +): FieldDescriptor[] => { + const parent = impl(desc).__parent; + if (!parent) { + return []; + } else { + const opaqueParent = opaque(parent) as FieldDescriptor; + return [opaqueParent, ...getParentsChain(opaqueParent)]; + } +}; + const getDependenciesValues = ( deps: readonly [...FieldDescTuple], getValue: GetValue @@ -286,24 +300,13 @@ const validatorMatchesField = ( ): boolean => { if (isNth(validator.field)) { const validatorRootPath = impl(validator.field).__rootPath; - const fieldRootPath = getRootArrayPath(impl(field).__path); + const fieldRootPath = impl(field).__parent?.__path; return validatorRootPath === fieldRootPath; } else { return impl(validator.field).__path === impl(field).__path; } }; -// TODO rethink -const getRootArrayPath = (path: string): string | undefined => { - const isArrayElement = path.lastIndexOf("]") === path.length - 1; - if (!isArrayElement) { - return undefined; - } else { - const indexStart = path.lastIndexOf("["); - return path.slice(0, indexStart); - } -}; - type FieldValidationKey = string; namespace FieldValidationKey { export const make = ( diff --git a/src/core/types/field-descriptor.ts b/src/core/types/field-descriptor.ts index 7640f3e..f12ca21 100644 --- a/src/core/types/field-descriptor.ts +++ b/src/core/types/field-descriptor.ts @@ -9,6 +9,7 @@ export type _FieldDescriptorImpl = { __path: string; __decoder: _FieldDecoderImpl; __lens: Lens; // TODO maybe add root typing Lens + __parent?: _FieldDescriptorImpl; }; export type _NTHHandler = {