diff --git a/package.json b/package.json index 2c5cc82..e3f5d01 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@commitlint/config-conventional": "^11.0.0", "@testing-library/react-hooks": "^3.4.2", "@types/jest": "~26.0.14", - "@types/react": "^16.9.49", + "@types/react": "^16.9.50", "@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.1.1", "add": "^2.0.6", diff --git a/src/core/hooks/use-formts/use-formts.spec.ts b/src/core/hooks/use-formts/use-formts.spec.ts index 228ab74..10acc70 100644 --- a/src/core/hooks/use-formts/use-formts.spec.ts +++ b/src/core/hooks/use-formts/use-formts.spec.ts @@ -4,7 +4,7 @@ import { createFormSchema } from "../../builders/create-form-schema"; import { useFormts } from "./use-formts"; -describe("use-formts", () => { +describe("useFormts", () => { const Schema = createFormSchema(fields => ({ theString: fields.string(), theChoice: fields.choice("A", "B", "C"), @@ -12,14 +12,18 @@ describe("use-formts", () => { theBool: fields.bool(), theInstance: fields.instanceOf(Date), theArray: fields.array(fields.string()), - theObject: fields.object({ foo: fields.string() }), + theObject: fields.object({ + foo: fields.string(), + }), theObjectArray: fields.object({ arr: fields.array(fields.string()) }), })); - it("returns values initialized to defaults", () => { + it("returns form handle with values initialized to defaults", () => { const hook = renderHook(() => useFormts({ Schema })); - expect(hook.result.current.values).toEqual({ + const [, form] = hook.result.current; + + expect(form.values).toEqual({ theString: "", theChoice: "A", theNum: "", @@ -31,7 +35,7 @@ describe("use-formts", () => { }); }); - it("returns values initialized to defaults merged with custom initial values", () => { + it("returns form handle with values initialized to defaults merged with custom initial values", () => { const hook = renderHook(() => useFormts({ Schema, @@ -44,7 +48,9 @@ describe("use-formts", () => { }) ); - expect(hook.result.current.values).toEqual({ + const [, form] = hook.result.current; + + expect(form.values).toEqual({ theString: "", theChoice: "C", theNum: 42, @@ -56,7 +62,7 @@ describe("use-formts", () => { }); }); - it("allows for getting values by corresponding FieldDescriptor", () => { + it("allows for getting values using corresponding field handles", () => { const hook = renderHook(() => useFormts({ Schema, @@ -69,157 +75,125 @@ describe("use-formts", () => { }) ); - const { getField } = hook.result.current; - expect(getField(Schema.theChoice)).toEqual("C"); - expect(getField(Schema.theNum)).toEqual(42); - expect(getField(Schema.theArray.root)).toEqual([ - "here", - "comes", - "the", - "sun", - ]); - expect(getField(Schema.theArray.nth(42))).toEqual(undefined); - expect(getField(Schema.theObject.root)).toEqual({ foo: "bar" }); - expect(getField(Schema.theObject.foo)).toEqual("bar"); - expect(getField(Schema.theObjectArray.root)).toEqual({ arr: [] }); + const [fields] = hook.result.current; + + expect(fields.theChoice.value).toEqual("C"); + expect(fields.theNum.value).toEqual(42); + expect(fields.theArray.value).toEqual(["here", "comes", "the", "sun"]); + expect(fields.theArray.children[42]?.value).toEqual(undefined); + expect(fields.theObject.value).toEqual({ foo: "bar" }); + expect(fields.theObject.children.foo.value).toEqual("bar"); + expect(fields.theObjectArray.value).toEqual({ arr: [] }); }); - it("allows for setting values by corresponding FieldDescriptor", () => { + it("allows for setting values using corresponding field handles and keeps track of touched state", () => { const hook = renderHook(() => useFormts({ Schema })); - expect(hook.result.current.values).toEqual({ - theString: "", - theChoice: "A", - theNum: "", - theBool: false, - theInstance: null, - theArray: [], - theObject: { foo: "" }, - theObjectArray: { arr: [] }, - }); - - act(() => { - hook.result.current.setField(Schema.theNum, 42); - }); - expect(hook.result.current.values).toEqual({ - theString: "", - theChoice: "A", - theNum: 42, - theBool: false, - theInstance: null, - theArray: [], - theObject: { foo: "" }, - theObjectArray: { arr: [] }, - }); + { + const [fields, form] = hook.result.current; + expect(form.values).toEqual({ + theString: "", + theChoice: "A", + theNum: "", + theBool: false, + theInstance: null, + theArray: [], + theObject: { foo: "" }, + theObjectArray: { arr: [] }, + }); + expect(fields.theString.isTouched).toBe(false); + expect(fields.theChoice.isTouched).toBe(false); + expect(fields.theNum.isTouched).toBe(false); + expect(fields.theBool.isTouched).toBe(false); + expect(fields.theInstance.isTouched).toBe(false); + expect(fields.theArray.isTouched).toBe(false); + expect(fields.theObject.isTouched).toBe(false); + expect(fields.theObject.children.foo.isTouched).toBe(false); + expect(fields.theObjectArray.isTouched).toBe(false); + expect(fields.theObjectArray.children.arr.isTouched).toBe(false); + } act(() => { - hook.result.current.setField(Schema.theArray.root, [ - "gumisie", - "teletubisie", - ]); - }); - expect(hook.result.current.values).toEqual({ - theString: "", - theChoice: "A", - theNum: 42, - theBool: false, - theInstance: null, - theArray: ["gumisie", "teletubisie"], - theObject: { foo: "" }, - theObjectArray: { arr: [] }, + const [fields] = hook.result.current; + fields.theNum.setValue(42); }); - act(() => { - hook.result.current.setField(Schema.theObject.foo, "42"); - }); - expect(hook.result.current.values).toEqual({ - theString: "", - theChoice: "A", - theNum: 42, - theBool: false, - theInstance: null, - theArray: ["gumisie", "teletubisie"], - theObject: { foo: "42" }, - theObjectArray: { arr: [] }, - }); + { + const [fields, form] = hook.result.current; + expect(fields.theNum.value).toBe(42); + expect(fields.theNum.isTouched).toBe(true); + expect(form.values).toEqual({ + theString: "", + theChoice: "A", + theNum: 42, + theBool: false, + theInstance: null, + theArray: [], + theObject: { foo: "" }, + theObjectArray: { arr: [] }, + }); + } act(() => { - hook.result.current.setField(Schema.theObjectArray.arr.nth(0), "hello"); + const [fields] = hook.result.current; + fields.theArray.setValue(["gumisie", "teletubisie"]); }); - expect(hook.result.current.values).toEqual({ - theString: "", - theChoice: "A", - theNum: 42, - theBool: false, - theInstance: null, - theArray: ["gumisie", "teletubisie"], - theObject: { foo: "42" }, - theObjectArray: { arr: ["hello"] }, - }); - }); - - it("keeps track of fields touched state", () => { - const hook = renderHook(() => useFormts({ Schema })); { - const { isTouched } = hook.result.current; - expect(isTouched(Schema.theString)).toBe(false); - expect(isTouched(Schema.theChoice)).toBe(false); - expect(isTouched(Schema.theNum)).toBe(false); - expect(isTouched(Schema.theBool)).toBe(false); - expect(isTouched(Schema.theInstance)).toBe(false); - expect(isTouched(Schema.theArray.root)).toBe(false); - expect(isTouched(Schema.theObject.root)).toBe(false); - expect(isTouched(Schema.theObject.foo)).toBe(false); - expect(isTouched(Schema.theObjectArray.root)).toBe(false); - expect(isTouched(Schema.theObjectArray.arr.root)).toBe(false); - } - - { - act(() => { - hook.result.current.setField(Schema.theNum, 42); + const [fields, form] = hook.result.current; + expect(fields.theArray.value).toEqual(["gumisie", "teletubisie"]); + expect(fields.theArray.isTouched).toBe(true); + expect(fields.theArray.children[0]?.value).toBe("gumisie"); + expect(fields.theArray.children[1]?.value).toBe("teletubisie"); + expect(fields.theArray.children[2]?.value).toBe(undefined); + expect(fields.theArray.children[0]?.isTouched).toBe(true); + expect(fields.theArray.children[1]?.isTouched).toBe(true); + expect(fields.theArray.children[2]?.isTouched).toBe(undefined); + expect(form.values).toEqual({ + theString: "", + theChoice: "A", + theNum: 42, + theBool: false, + theInstance: null, + theArray: ["gumisie", "teletubisie"], + theObject: { foo: "" }, + theObjectArray: { arr: [] }, }); - expect(hook.result.current.isTouched(Schema.theNum)).toBe(true); } - { - act(() => { - hook.result.current.setField(Schema.theArray.root, [ - "gumisie", - "teletubisie", - ]); - }); - const { isTouched } = hook.result.current; - - expect(isTouched(Schema.theArray.root)).toBe(true); - expect(isTouched(Schema.theArray.nth(0))).toBe(true); - expect(isTouched(Schema.theArray.nth(1))).toBe(true); - expect(isTouched(Schema.theArray.nth(2))).toBe(false); - } + act(() => { + const [fields] = hook.result.current; + fields.theObject.children.foo.setValue("42"); + }); { - act(() => { - hook.result.current.setField(Schema.theObject.foo, "42"); + const [fields, form] = hook.result.current; + expect(fields.theObject.value).toEqual({ foo: "42" }); + expect(fields.theObject.isTouched).toBe(true); + expect(fields.theObject.children.foo.value).toEqual("42"); + expect(fields.theObject.children.foo.isTouched).toBe(true); + expect(form.values).toEqual({ + theString: "", + theChoice: "A", + theNum: 42, + theBool: false, + theInstance: null, + theArray: ["gumisie", "teletubisie"], + theObject: { foo: "42" }, + theObjectArray: { arr: [] }, }); - const { isTouched } = hook.result.current; - - expect(isTouched(Schema.theObject.foo)).toBe(true); - expect(isTouched(Schema.theObject.root)).toBe(true); } + }); - { - act(() => { - hook.result.current.setField(Schema.theObjectArray.root, { - arr: ["a", "b", "c"], - }); - }); - const { isTouched } = hook.result.current; + it("exposes options of choice fields", () => { + const hook = renderHook(() => useFormts({ Schema })); - expect(isTouched(Schema.theObjectArray.root)).toBe(true); - expect(isTouched(Schema.theObjectArray.arr.root)).toBe(true); - expect(isTouched(Schema.theObjectArray.arr.nth(0))).toBe(true); - expect(isTouched(Schema.theObjectArray.arr.nth(1))).toBe(true); - expect(isTouched(Schema.theObjectArray.arr.nth(2))).toBe(true); - } + const [fields] = hook.result.current; + + expect(fields.theChoice.options).toEqual({ + A: "A", + B: "B", + C: "C", + }); }); }); diff --git a/src/core/hooks/use-formts/use-formts.ts b/src/core/hooks/use-formts/use-formts.ts index c5c7234..51509f1 100644 --- a/src/core/hooks/use-formts/use-formts.ts +++ b/src/core/hooks/use-formts/use-formts.ts @@ -1,14 +1,30 @@ import React from "react"; -import { DeepPartial, get } from "../../../utils"; +import { DeepPartial, get, keys, logger, toIdentityDict } from "../../../utils"; +import { + isChoiceDecoder, + _ChoiceFieldDecoderImpl, +} from "../../types/field-decoder"; import { FieldDescriptor, _FieldDescriptorImpl, } from "../../types/field-descriptor"; -import { FormSchema } from "../../types/form-schema"; -import { TouchedValues } from "../../types/formts-state"; -import { impl } from "../../types/type-mapper-util"; +import { FieldHandle, toFieldHandle } from "../../types/field-handle"; +import { FieldHandleSchema } from "../../types/field-handle-schema"; +import { FormHandle } from "../../types/form-handle"; +import { + FormSchema, + GenericFormDescriptorSchema, +} from "../../types/form-schema"; +import { + isArrayDesc, + isObjectDesc, + objectDescriptorKeys, + _DescriptorApprox_, +} from "../../types/form-schema-approx"; +import { impl, opaque } from "../../types/type-mapper-util"; +import { createInitialValues } from "./create-initial-values"; import { createReducer, getInitialState } from "./reducer"; import { resolveTouched } from "./resolve-touched"; @@ -24,35 +40,142 @@ export type FormtsOptions = { initialValues?: DeepPartial; }; -type TemporaryFormtsReturn = { - values: Values; - touched: TouchedValues; - getField: (field: FieldDescriptor) => T; - setField: (field: FieldDescriptor, value: T) => void; - isTouched: (field: FieldDescriptor) => boolean; - touchField: (field: FieldDescriptor) => void; -}; +type FormtsReturn = [ + fields: FieldHandleSchema, + form: Partial> // TODO +]; export const useFormts = ( options: FormtsOptions -): TemporaryFormtsReturn => { +): FormtsReturn => { + /// INTERNAL STATE const [state, dispatch] = React.useReducer( createReducer(), options, getInitialState ); + /// INTERNAL HANDLERS const getField = (field: FieldDescriptor): T => get(state.values, impl(field).path) as any; const isTouched = (field: FieldDescriptor) => resolveTouched(get(state.touched as object, impl(field).path)); - const setField = (field: FieldDescriptor, value: T): void => - dispatch({ type: "setValue", payload: { path: impl(field).path, value } }); - const touchField = (field: FieldDescriptor) => dispatch({ type: "touchValue", payload: { path: impl(field).path } }); - return { ...state, getField, setField, isTouched, touchField }; + const setField = (field: FieldDescriptor, value: T): void => { + const decodeResult = impl(field).decode(value); + if (decodeResult.ok) { + dispatch({ + type: "setValue", + payload: { path: impl(field).path, value: decodeResult.value }, + }); + } else { + logger.warn( + `Field ${impl(field).path} received illegal value: ${JSON.stringify( + value + )}` + ); + } + }; + + /// FIELD HANDLE CREATOR + const createFieldHandleNode = ( + // TODO: consider flattening descriptors, so that `Schema.obj` -> root and `Schema.obj.prop` -> prop + _descriptor: GenericFormDescriptorSchema + ): FieldHandle => { + const descriptor = (_descriptor as any) as _DescriptorApprox_; + const rootDescriptor = + isArrayDesc(descriptor) || isObjectDesc(descriptor) + ? descriptor.root + : descriptor; + + return toFieldHandle({ + descriptor: opaque(rootDescriptor), + + id: rootDescriptor.path, + + get value() { + return getField(opaque(rootDescriptor)); + }, + + get isTouched() { + return isTouched(opaque(rootDescriptor)); + }, + + get children() { + if (isObjectDesc(descriptor)) { + return objectDescriptorKeys(descriptor).reduce( + (acc, key) => + Object.defineProperty(acc, key, { + enumerable: true, + get: function () { + const nestedDescriptor = descriptor[key]; + return createFieldHandleNode(nestedDescriptor as any); + }, + }), + {} + ); + } + + if (isArrayDesc(descriptor)) { + const value = (getField(opaque(rootDescriptor)) as any) as Array< + unknown + >; + return value.map((_, idx) => + createFieldHandleNode(descriptor.nth(idx) as any) + ); + } + + return undefined; + }, + + get options() { + return isChoiceDecoder(descriptor) + ? toIdentityDict(descriptor.options as string[]) + : undefined; + }, + + handleBlur: () => { + touchField(opaque(rootDescriptor)); + }, + + setValue: val => { + setField(opaque(rootDescriptor), val); + }, + }); + }; + + /// PUBLIC API + const fields = keys(options.Schema).reduce( + (acc, key) => + Object.defineProperty(acc, key, { + enumerable: true, + get: function () { + const nestedDescriptor = options.Schema[key]; + return createFieldHandleNode(nestedDescriptor); + }, + }), + {} as FieldHandleSchema + ); + + // TODO + const form: Partial> = { + values: state.values, + + get isTouched() { + return resolveTouched(state.touched); + }, + + reset: values => { + dispatch({ + type: "reset", + payload: { values: createInitialValues(options.Schema, values) }, + }); + }, + }; + + return [fields, form]; }; diff --git a/src/core/types/field-decoder.ts b/src/core/types/field-decoder.ts index fcbee37..1fda581 100644 --- a/src/core/types/field-decoder.ts +++ b/src/core/types/field-decoder.ts @@ -27,6 +27,23 @@ export type _ChoiceFieldDecoderImpl = _FieldDecoderBaseImpl & { options: T[]; }; +export const isChoiceDecoder = ( + it: unknown +): it is _ChoiceFieldDecoderImpl => + typeof (it as any).decode === "function" && + (it as any).fieldType === "choice"; + +export const isObjectDecoder = ( + it: unknown +): it is _ObjectFieldDecoderImpl => + typeof (it as any).decode === "function" && + (it as any).fieldType === "object"; + +export const isArrayDecoder = ( + it: unknown +): it is _ArrayFieldDecoderImpl => + typeof (it as any).decode === "function" && (it as any).fieldType === "array"; + /** * Object containing run-time type information about a field. * Should be used together with `createForm.schema` function. diff --git a/src/core/types/field-handle.ts b/src/core/types/field-handle.ts index efd262c..65b2258 100644 --- a/src/core/types/field-handle.ts +++ b/src/core/types/field-handle.ts @@ -10,7 +10,19 @@ export type FieldHandle = & BaseFieldHandle & ArrayFieldHandle & ObjectFieldHandle - & ChoiceFieldHandle; + & ChoiceFieldHandle; + +export type _FieldHandleApprox = BaseFieldHandle & { + children?: + | Array<_FieldHandleApprox> + | Record>; + options?: Record; +}; + +export const toApproxFieldHandle = (it: FieldHandle) => + it as _FieldHandleApprox; +export const toFieldHandle = (it: _FieldHandleApprox) => + it as FieldHandle; type BaseFieldHandle = { /** Unique string generated for each field in the form based on field path */ @@ -94,7 +106,7 @@ type ObjectFieldHandle = T extends Array } : void; -type ChoiceFieldHandle = [T] extends [string] +type ChoiceFieldHandle = [T] extends [string] ? IsUnion extends true ? { /** Dictionary containing options specified in Schema using `choice` function */ diff --git a/src/core/types/form-handle.ts b/src/core/types/form-handle.ts index 9446e43..09d3fa3 100644 --- a/src/core/types/form-handle.ts +++ b/src/core/types/form-handle.ts @@ -1,3 +1,5 @@ +import { DeepPartial } from "../../utils"; + import { FieldDescriptor } from "./field-descriptor"; type FieldError = { @@ -31,7 +33,7 @@ export type FormHandle = { * Resets the form cleaning all validation errors and touched flags. * Form values will be set to initial values or to provided object. */ - reset: (values?: Values) => void; + reset: (values?: DeepPartial) => void; /** * Runs validation of all fields. diff --git a/src/core/types/form-schema-approx.ts b/src/core/types/form-schema-approx.ts index 114c4aa..6d00236 100644 --- a/src/core/types/form-schema-approx.ts +++ b/src/core/types/form-schema-approx.ts @@ -1,4 +1,4 @@ -import { ArrayElement } from "../../utils"; +import { ArrayElement, keys } from "../../utils"; import { _FieldDescriptorImpl } from "./field-descriptor"; import { FormSchema } from "./form-schema"; @@ -34,3 +34,7 @@ export const isArrayDesc = ( export const isObjectDesc = ( x: _DescriptorApprox_ ): x is _ObjectDescriptorApprox_ => "root" in x && !("nth" in x); + +export const objectDescriptorKeys = ( + x: _ObjectDescriptorApprox_ +): (keyof Value)[] => keys(x).filter(x => x !== "root") as any; diff --git a/src/core/types/type-mapper-util.ts b/src/core/types/type-mapper-util.ts index 818f968..b2e6e85 100644 --- a/src/core/types/type-mapper-util.ts +++ b/src/core/types/type-mapper-util.ts @@ -7,8 +7,8 @@ import { FieldDescriptor, _FieldDescriptorImpl } from "./field-descriptor"; */ export const impl: GetImplFn = (it: any) => it; type GetImplFn = { - (it: FieldDecoder): _FieldDecoderImpl; (it: FieldDescriptor): _FieldDescriptorImpl; + (it: FieldDecoder): _FieldDecoderImpl; }; /** @@ -17,6 +17,6 @@ type GetImplFn = { */ export const opaque: GetOpaque = (it: any) => it; type GetOpaque = { - (it: _FieldDecoderImpl): FieldDecoder; (it: _FieldDescriptorImpl): FieldDescriptor; + (it: _FieldDecoderImpl): FieldDecoder; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 042c42b..d45701f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./utility-types"; export * from "./misc-utils"; export * from "./object"; +export * from "./logger"; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..b9f8138 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,9 @@ +const warn = (message?: unknown, ...params: unknown[]) => { + // TODO: is that the way to check for DEV consumer side? + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.warn(message, ...params); + } +}; + +export const logger = { warn }; diff --git a/src/utils/object.ts b/src/utils/object.ts index d5e0988..48f0ced 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -1,4 +1,4 @@ -import { DeepPartial } from "./utility-types"; +import { DeepPartial, IdentityDict } from "./utility-types"; export const entries = (o: T): [keyof T, T[keyof T]][] => Object.entries(o) as any; @@ -9,6 +9,14 @@ export const keys = (o: T): (keyof T)[] => export const values = (o: T): T[keyof T][] => Object.values(o) as T[keyof T][]; +export const toIdentityDict = ( + values: T[] +): IdentityDict => + values.reduce((dict, val) => { + (dict as any)[val] = val; + return dict; + }, {} as IdentityDict); + export const isPlainObject = (it: unknown): it is object => it != null && typeof it === "object" && (it as any).constructor === Object; diff --git a/tsconfig.json b/tsconfig.json index a73ae7c..3d0e8ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,8 @@ "experimentalDecorators": false, "emitDecoratorMetadata": false, - "lib": ["es2017", "dom"] + "lib": ["es2017", "dom"], + "target": "ES2017" }, "include": ["src/**/*"], "exclude": ["node_modules/**"], diff --git a/yarn.lock b/yarn.lock index ab94a43..7eb239c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -797,18 +797,10 @@ dependencies: "@types/react" "*" -"@types/react@*": - version "16.9.50" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.50.tgz#cb5f2c22d42de33ca1f5efc6a0959feb784a3a2d" - integrity sha512-kPx5YsNnKDJejTk1P+lqThwxN2PczrocwsvqXnjvVvKpFescoY62ZiM3TV7dH1T8lFhlHZF+PE5xUyimUwqEGA== - dependencies: - "@types/prop-types" "*" - csstype "^3.0.2" - -"@types/react@^16.9.49": - version "16.9.49" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" - integrity sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g== +"@types/react@*", "@types/react@^16.9.50": + version "16.9.51" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.51.tgz#f8aa51ffa9996f1387f63686696d9b59713d2b60" + integrity sha512-lQa12IyO+DMlnSZ3+AGHRUiUcpK47aakMMoBG8f7HGxJT8Yfe+WE128HIXaHOHVPReAW0oDS3KAI0JI2DDe1PQ== dependencies: "@types/prop-types" "*" csstype "^3.0.2"