Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): form schema creator #8

Merged
merged 10 commits into from
Sep 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@typescript-eslint/eslint-plugin": "^4.1.1",
"@typescript-eslint/parser": "^4.1.1",
"add": "^2.0.6",
"conditional-type-checks": "^1.0.5",
"eslint": "^7.9.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.22.0",
Expand Down
254 changes: 254 additions & 0 deletions src/core/builders/create-form-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { assert, IsExact } from "conditional-type-checks";

import { FormSchema } from "../types/form-schema";
import { impl } from "../types/type-mapper-util";

import { createFormSchema } from "./create-form-schema";

describe("createFormSchema", () => {
it("creates FormSchema type based on field decoders with default error type", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

type Actual = typeof Schema;
type Expected = FormSchema<
{
string: string;
choice: "A" | "B" | "C";
num: number | "";
bool: boolean;
arrayString: string[];
arrayChoice: Array<"a" | "b" | "c">;
arrayArrayString: string[][];
instance: Date | null;
},
never
>;

assert<IsExact<Actual, Expected>>(true);
});

it("creates FormSchema type based on field decoders with custom error type", () => {
const Schema = createFormSchema(
fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}),
error => error<"ERR_1" | "ERR_2">()
);

type Actual = typeof Schema;
type Expected = FormSchema<
{
string: string;
choice: "A" | "B" | "C";
num: number | "";
bool: boolean;
arrayString: string[];
arrayChoice: Array<"a" | "b" | "c">;
arrayArrayString: string[][];
instance: Date | null;
},
"ERR_1" | "ERR_2"
>;

assert<IsExact<Actual, Expected>>(true);
});

it("creates empty schema object when no fields are specified", () => {
const Schema = createFormSchema(() => ({}));

expect(Object.keys(Schema)).toEqual([]);
});

it("creates schema object with keys for every field", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

expect(Object.keys(Schema)).toEqual([
"string",
"choice",
"num",
"bool",
"arrayString",
"arrayChoice",
"arrayArrayString",
"instance",
]);
});

it("creates schema object with paths for every field", () => {
const Schema = createFormSchema(fields => ({
string: fields.string(),
choice: fields.choice("A", "B", "C"),
num: fields.number(),
bool: fields.bool(),
arrayString: fields.array(fields.string()),
arrayChoice: fields.array(fields.choice("a", "b", "c")),
arrayArrayString: fields.array(fields.array(fields.string())),
instance: fields.instanceOf(Date),
}));

expect(Schema).toEqual({
string: expect.objectContaining({ path: "string" }),
choice: expect.objectContaining({ path: "choice" }),
num: expect.objectContaining({ path: "num" }),
bool: expect.objectContaining({ path: "bool" }),
arrayString: expect.objectContaining({
root: expect.objectContaining({ path: "arrayString" }),
}),
arrayChoice: expect.objectContaining({
root: expect.objectContaining({ path: "arrayChoice" }),
}),
arrayArrayString: expect.objectContaining({
root: expect.objectContaining({ path: "arrayArrayString" }),
}),
instance: expect.objectContaining({ path: "instance" }),
});
});

it("creates field descriptor for string field", () => {
const Schema = createFormSchema(fields => ({
theString: fields.string(),
}));

const descriptor = impl(Schema.theString);

expect(descriptor.fieldType).toBe("string");
expect(descriptor.path).toBe("theString");
expect(descriptor.init()).toBe("");
expect(descriptor.decode("foo").ok).toBe(true);
expect(descriptor.decode(42).ok).toBe(false);
});

it("creates field descriptor for choice field", () => {
const Schema = createFormSchema(fields => ({
theChoice: fields.choice("A", "B", "C"),
}));

const descriptor = impl(Schema.theChoice);

expect(descriptor.fieldType).toBe("choice");
expect(descriptor.path).toBe("theChoice");
expect(descriptor.options).toEqual(["A", "B", "C"]);
expect(descriptor.init()).toBe("A");
expect(descriptor.decode("C").ok).toBe(true);
expect(descriptor.decode("foo").ok).toBe(false);
});

it("does not allow creating schema with empty choice field", () => {
createFormSchema(fields => ({
// @ts-expect-error
theChoice: fields.choice(),
}));
});

it("creates field descriptor for number field", () => {
const Schema = createFormSchema(fields => ({
theNumber: fields.number(),
}));

const descriptor = impl(Schema.theNumber);

expect(descriptor.fieldType).toBe("number");
expect(descriptor.path).toBe("theNumber");
expect(descriptor.init()).toBe("");
expect(descriptor.decode(666).ok).toBe(true);
expect(descriptor.decode("42").ok).toBe(false);
});

it("creates field descriptor for bool field", () => {
const Schema = createFormSchema(fields => ({
theBoolean: fields.bool(),
}));

const descriptor = impl(Schema.theBoolean);

expect(descriptor.fieldType).toBe("bool");
expect(descriptor.path).toBe("theBoolean");
expect(descriptor.init()).toBe(false);
expect(descriptor.decode(true).ok).toBe(true);
expect(descriptor.decode("true").ok).toBe(false);
});

it("creates field descriptor for array field", () => {
const Schema = createFormSchema(fields => ({
theArray: fields.array(fields.string()),
}));

const rootDescriptor = impl(Schema.theArray.root);

expect(rootDescriptor.fieldType).toBe("array");
expect(rootDescriptor.path).toBe("theArray");
expect(rootDescriptor.init()).toEqual([]);
expect(rootDescriptor.decode(["foo", "bar"]).ok).toBe(true);
expect(rootDescriptor.decode("foo").ok).toBe(false);
expect(rootDescriptor.inner.fieldType).toBe("string");

const elementDescriptor = impl(Schema.theArray.nth(42));

expect(elementDescriptor.fieldType).toBe("string");
expect(elementDescriptor.path).toBe("theArray[42]");
expect(elementDescriptor.init()).toEqual("");
expect(elementDescriptor.decode("foo").ok).toBe(true);
expect(elementDescriptor.decode(["foo", "bar"]).ok).toBe(false);
});

it("creates field descriptor for nested array field", () => {
const Schema = createFormSchema(fields => ({
theArray: fields.array(fields.array(fields.number())),
}));

const rootDescriptor = impl(Schema.theArray.root);
expect(rootDescriptor.fieldType).toBe("array");
expect(rootDescriptor.path).toBe("theArray");

const elementDescriptor = impl(Schema.theArray.nth(42).root);
expect(elementDescriptor.fieldType).toBe("array");
expect(elementDescriptor.path).toBe("theArray[42]");

const elementElementDescriptor = impl(Schema.theArray.nth(42).nth(666));
expect(elementElementDescriptor.fieldType).toBe("number");
expect(elementElementDescriptor.path).toBe("theArray[42][666]");
});

it("creates field descriptor for instanceOf field", () => {
class MyClass {
constructor(public foo: string) {}
}

const Schema = createFormSchema(fields => ({
theClass: fields.instanceOf(MyClass),
}));

const descriptor = impl(Schema.theClass);

expect(descriptor.fieldType).toBe("class");
expect(descriptor.path).toBe("theClass");
expect(descriptor.init()).toBe(null);
expect(descriptor.decode(new MyClass("42")).ok).toBe(true);
expect(descriptor.decode({ foo: "42" }).ok).toBe(false);
});
});
94 changes: 94 additions & 0 deletions src/core/builders/create-form-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { assertNever } from "../../utils";
import { bool, string, number, choice, array, instanceOf } from "../decoders";
import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder";
import { _FieldDescriptorImpl } from "../types/field-descriptor";
import { FormSchema } from "../types/form-schema";
import { impl } from "../types/type-mapper-util";

const Decoders = {
bool,
string,
number,
choice,
array,
instanceOf,
};

type BuilderFn<V> = (
fields: typeof Decoders
) => { [K in keyof V]: FieldDecoder<V[K]> };

type ErrorsMarker<Err> = (errors: <Err>() => Err) => Err;

// loose typing for helping with internal impl, as working with generic target types is impossible
type _FormSchemaApprox_ = Record<string, _DescriptorApprox_>;

type _DescriptorApprox_ =
| _FieldDescriptorImpl<any>
| {
readonly root: _FieldDescriptorImpl<any>;
readonly nth: (index: number) => _DescriptorApprox_;
};

/**
* Define shape of the form values and type of validation errors.
* This is used not only for compile-time type-safety but also for runtime validation of form values.
* The schema can be defined top-level, so that it can be exported to nested Form components for usage together with `useField` hook.
*
* @returns
* FormSchema - used to interact with Formts API and point to specific form fields
*
* @example
* ```
* const Schema = createForm.schema(
* fields => ({
* name: fields.string(),
* age: fields.number(),
* }),
* errors => errors<string>()
* );
* ```
*/
export const createFormSchema = <Values extends object, Err = never>(
fields: BuilderFn<Values>,
_errors?: ErrorsMarker<Err>
): FormSchema<Values, Err> => {
const decodersMap = fields(Decoders);

return Object.keys(decodersMap).reduce<_FormSchemaApprox_>((schema, key) => {
const decoder = decodersMap[key as keyof Values];
schema[key] = createFieldDescriptor(
impl(decoder) as _FieldDecoderImpl<any>,
key
);
return schema;
}, {}) as any;
};

const createFieldDescriptor = (
decoder: _FieldDecoderImpl<any>,
path: string
): _DescriptorApprox_ => {
switch (decoder.fieldType) {
case "bool":
case "number":
case "string":
case "class":
case "choice":
return { ...decoder, path };

case "array": {
const root = { ...decoder, path };
const nth = (i: number) =>
createFieldDescriptor(
decoder.inner as _FieldDecoderImpl<any>,
`${path}[${i}]`
);

return { root, nth };
}

default:
return assertNever(decoder.fieldType);
}
};
10 changes: 10 additions & 0 deletions src/core/builders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createFormSchema } from "./create-form-schema";

/**
* container for form builder functions
*/
export const createForm = {
schema: createFormSchema,
// validator: createFormValidator, // TODO
// transformer: createFormTransformer, // TODO
};
Loading