Skip to content

Commit

Permalink
fix(core): child validation triggers parent validation (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
pidkopajo authored Mar 19, 2021
1 parent e6db8e4 commit 8eaab1f
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 18 deletions.
21 changes: 16 additions & 5 deletions src/core/builders/create-form-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ export const createFormSchema = <Values extends object, Err = never>(
const createObjectSchema = <O extends object, Root>(
decodersMap: DecodersMap<O>,
lens: Lens<Root, O>,
path?: string
path?: string,
parent?: _FieldDescriptorImpl<unknown>
) => {
return keys(decodersMap).reduce((schema, key) => {
const decoder = decodersMap[key];
(schema as any)[key] = createFieldDescriptor(
impl(decoder) as _FieldDecoderImpl<any>,
Lens.compose(lens, Lens.prop(key as any)),
path ? `${path}.${key}` : `${key}`
path ? `${path}.${key}` : `${key}`,
parent
);
return schema;
}, {} as FormSchema<O, unknown>);
Expand All @@ -56,7 +58,8 @@ const createObjectSchema = <O extends object, Root>(
const createFieldDescriptor = (
decoder: _FieldDecoderImpl<any>,
lens: Lens<any, any>,
path: string
path: string,
parent?: _FieldDescriptorImpl<unknown>
): _FieldDescriptorImpl<any> => {
// these properties are hidden implementation details and thus should not be enumerable
const rootDescriptor = defineProperties(
Expand All @@ -80,6 +83,12 @@ const createFieldDescriptor = (
writable: false,
configurable: false,
},
__parent: {
value: parent,
enumerable: false,
writable: false,
configurable: false,
},
}
);

Expand All @@ -96,7 +105,8 @@ const createFieldDescriptor = (
createFieldDescriptor(
decoder.inner as _FieldDecoderImpl<any>,
Lens.compose(lens, Lens.index(i)),
`${path}[${i}]`
`${path}[${i}]`,
rootDescriptor
);

const nth = defineProperties(nthHandler, {
Expand All @@ -115,7 +125,8 @@ const createFieldDescriptor = (
const props = createObjectSchema(
decoder.inner as DecodersMap<unknown>,
lens,
path
path,
rootDescriptor
);
return Object.assign(rootDescriptor, props);
}
Expand Down
111 changes: 111 additions & 0 deletions src/core/builders/create-form-validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">()
);
Expand Down Expand Up @@ -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<any> | 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<any> | 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", () => {
Expand Down
29 changes: 16 additions & 13 deletions src/core/builders/create-form-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ export const createFormValidator = <Values extends object, Err>(
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
);

Expand Down Expand Up @@ -273,6 +275,18 @@ const getDependents = <Err>(
}
});

const getParentsChain = <Err>(
desc: FieldDescriptor<any, Err>
): FieldDescriptor<any, Err>[] => {
const parent = impl(desc).__parent;
if (!parent) {
return [];
} else {
const opaqueParent = opaque(parent) as FieldDescriptor<any, Err>;
return [opaqueParent, ...getParentsChain(opaqueParent)];
}
};

const getDependenciesValues = <Values extends readonly any[], Err>(
deps: readonly [...FieldDescTuple<Values, Err>],
getValue: GetValue<Err>
Expand All @@ -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 = (
Expand Down
1 change: 1 addition & 0 deletions src/core/types/field-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type _FieldDescriptorImpl<T> = {
__path: string;
__decoder: _FieldDecoderImpl<T>;
__lens: Lens<any, T>; // TODO maybe add root typing Lens<Root, T>
__parent?: _FieldDescriptorImpl<unknown>;
};

export type _NTHHandler<T> = {
Expand Down

0 comments on commit 8eaab1f

Please sign in to comment.