diff --git a/src/core/context/form-provider.tsx b/src/core/context/form-provider.tsx index 3c14da4..a3448ec 100644 --- a/src/core/context/form-provider.tsx +++ b/src/core/context/form-provider.tsx @@ -10,6 +10,7 @@ const Context = React.createContext< type FormProviderProps = { controller: FormController; + children: React.ReactNode; }; /** diff --git a/src/core/hooks/hooks.spec.tsx b/src/core/hooks/hooks.spec.tsx index db39343..b764311 100644 --- a/src/core/hooks/hooks.spec.tsx +++ b/src/core/hooks/hooks.spec.tsx @@ -455,6 +455,109 @@ describe("formts hooks API", () => { } }); + it("keeps track of isChanged flag for individual fields and the form as a whole", () => { + const { result: controllerHook } = renderHook(() => + useFormController({ + Schema, + initialValues: { + theNum: 42, + theObjectArray: { arr: ["1", "2"] }, + }, + }) + ); + const { + result: formHandleHook, + rerender: rerenderFormHandleHook, + } = renderHook(() => useFormHandle(Schema, controllerHook.current)); + const { + result: numberFieldHook, + rerender: rerenderNumberFieldHook, + } = renderHook(() => useField(Schema.theNum, controllerHook.current)); + const { + result: objectArrayFieldHook, + rerender: rerenderObjectArrayFieldHook, + } = renderHook(() => + useField(Schema.theObjectArray, controllerHook.current) + ); + const { + result: nestedArrayFieldHook, + rerender: rerenderNestedArrayFieldHook, + } = renderHook(() => + useField(Schema.theObjectArray.arr, controllerHook.current) + ); + + const rerenderHooks = () => { + rerenderFormHandleHook(); + rerenderNumberFieldHook(); + rerenderObjectArrayFieldHook(); + rerenderNestedArrayFieldHook(); + }; + + { + expect(formHandleHook.current.isChanged).toBe(false); + expect(numberFieldHook.current.isChanged).toBe(false); + expect(objectArrayFieldHook.current.isChanged).toBe(false); + expect(nestedArrayFieldHook.current.isChanged).toBe(false); + } + + { + act(() => { + numberFieldHook.current.setValue(1); + rerenderHooks(); + }); + } + + { + expect(formHandleHook.current.isChanged).toBe(true); + expect(numberFieldHook.current.isChanged).toBe(true); + expect(objectArrayFieldHook.current.isChanged).toBe(false); + expect(nestedArrayFieldHook.current.isChanged).toBe(false); + } + + { + act(() => { + nestedArrayFieldHook.current.setValue(["1", "2"]); + rerenderHooks(); + }); + } + + { + expect(formHandleHook.current.isChanged).toBe(true); + expect(numberFieldHook.current.isChanged).toBe(true); + expect(objectArrayFieldHook.current.isChanged).toBe(false); + expect(nestedArrayFieldHook.current.isChanged).toBe(false); + } + + { + act(() => { + nestedArrayFieldHook.current.setValue(["1", "2", "3"]); + rerenderHooks(); + }); + } + + { + expect(formHandleHook.current.isChanged).toBe(true); + expect(numberFieldHook.current.isChanged).toBe(true); + expect(objectArrayFieldHook.current.isChanged).toBe(true); + expect(nestedArrayFieldHook.current.isChanged).toBe(true); + } + + { + act(() => { + numberFieldHook.current.setValue(42); + nestedArrayFieldHook.current.setValue(["1", "2"]); + rerenderHooks(); + }); + } + + { + expect(formHandleHook.current.isChanged).toBe(false); + expect(numberFieldHook.current.isChanged).toBe(false); + expect(objectArrayFieldHook.current.isChanged).toBe(false); + expect(nestedArrayFieldHook.current.isChanged).toBe(false); + } + }); + it("allows for setting field values based on change events", () => { const { result: controllerHook } = renderHook(() => useFormController({ Schema }) @@ -703,6 +806,7 @@ describe("formts hooks API", () => { }); { + expect(formHandleHook.current.isChanged).toBe(true); expect(formHandleHook.current.isValid).toBe(false); expect(formHandleHook.current.isTouched).toBe(true); expect(formValuesHook.current).toEqual({ @@ -723,6 +827,7 @@ describe("formts hooks API", () => { }); { + expect(formHandleHook.current.isChanged).toBe(false); expect(formHandleHook.current.isValid).toBe(true); expect(formHandleHook.current.isTouched).toBe(false); expect(formValuesHook.current).toEqual({ @@ -767,14 +872,17 @@ describe("formts hooks API", () => { }; { + expect(numberFieldHook.current.isChanged).toBe(false); expect(numberFieldHook.current.isTouched).toBe(false); expect(numberFieldHook.current.isValid).toBe(true); expect(numberFieldHook.current.value).toEqual(""); + expect(objectFieldHook.current.isChanged).toBe(false); expect(objectFieldHook.current.isTouched).toBe(false); expect(objectFieldHook.current.isValid).toBe(true); expect(objectFieldHook.current.value).toEqual({ foo: "42" }); + expect(nestedFooFieldHook.current.isChanged).toBe(false); expect(nestedFooFieldHook.current.isTouched).toBe(false); expect(nestedFooFieldHook.current.isValid).toBe(true); expect(nestedFooFieldHook.current.value).toEqual("42"); @@ -790,14 +898,17 @@ describe("formts hooks API", () => { } { + expect(numberFieldHook.current.isChanged).toBe(true); expect(numberFieldHook.current.isTouched).toBe(true); expect(numberFieldHook.current.isValid).toBe(true); expect(numberFieldHook.current.value).toEqual(42); + expect(objectFieldHook.current.isChanged).toBe(true); expect(objectFieldHook.current.isTouched).toBe(true); expect(objectFieldHook.current.isValid).toBe(false); expect(objectFieldHook.current.value).toEqual({ foo: "24" }); + expect(nestedFooFieldHook.current.isChanged).toBe(true); expect(nestedFooFieldHook.current.isTouched).toBe(true); expect(nestedFooFieldHook.current.isValid).toBe(true); expect(nestedFooFieldHook.current.value).toEqual("24"); @@ -811,14 +922,17 @@ describe("formts hooks API", () => { } { + expect(numberFieldHook.current.isChanged).toBe(true); expect(numberFieldHook.current.isTouched).toBe(true); expect(numberFieldHook.current.isValid).toBe(true); expect(numberFieldHook.current.value).toEqual(42); + expect(objectFieldHook.current.isChanged).toBe(false); expect(objectFieldHook.current.isTouched).toBe(false); expect(objectFieldHook.current.isValid).toBe(true); expect(objectFieldHook.current.value).toEqual({ foo: "42" }); + expect(nestedFooFieldHook.current.isChanged).toBe(false); expect(nestedFooFieldHook.current.isTouched).toBe(false); expect(nestedFooFieldHook.current.isValid).toBe(true); expect(nestedFooFieldHook.current.value).toEqual("42"); diff --git a/src/core/hooks/use-field/use-field.ts b/src/core/hooks/use-field/use-field.ts index bd66161..faa3c10 100644 --- a/src/core/hooks/use-field/use-field.ts +++ b/src/core/hooks/use-field/use-field.ts @@ -91,6 +91,10 @@ const createFieldHandle = ( ); }, + get isChanged() { + return fieldState.val.changed; + }, + get error() { return formState.errors.val[impl(descriptor).__path] ?? null; }, diff --git a/src/core/hooks/use-form-controller/atom-cache.ts b/src/core/hooks/use-form-controller/atom-cache.ts index 7edf4bc..80299cc 100644 --- a/src/core/hooks/use-form-controller/atom-cache.ts +++ b/src/core/hooks/use-form-controller/atom-cache.ts @@ -1,3 +1,4 @@ +import { deepEqual } from "../../../utils"; import { Atom } from "../../../utils/atoms"; import * as Helpers from "../../helpers"; import { FieldDescriptor } from "../../types/field-descriptor"; @@ -8,6 +9,7 @@ type FieldPath = string; export type FieldStateAtom = Atom.Readonly<{ value: T; + changed: boolean; touched: TouchedValues; formSubmitted: boolean; }>; @@ -41,15 +43,18 @@ export class FieldStateAtomCache { field: FieldDescriptor ): FieldStateAtom { const lens = impl(field).__lens; + const initialValue = lens.get(this.formtsState.initialValues); + const fieldValueAtom = Atom.entangle(this.formtsState.values, lens); return Atom.fuse( - (value, touched, formSubmitted) => ({ + (value, changed, touched, formSubmitted) => ({ value, + changed, touched: touched as any, formSubmitted, }), - - Atom.entangle(this.formtsState.values, lens), + fieldValueAtom, + Atom.fuse(value => !deepEqual(value, initialValue), fieldValueAtom), Atom.entangle(this.formtsState.touched, lens), Atom.fuse( (sc, fc) => sc + fc > 0, diff --git a/src/core/hooks/use-form-handle/use-form-handle.ts b/src/core/hooks/use-form-handle/use-form-handle.ts index a533cc4..b7b8f5c 100644 --- a/src/core/hooks/use-form-handle/use-form-handle.ts +++ b/src/core/hooks/use-form-handle/use-form-handle.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { keys, values } from "../../../utils"; +import { deepEqual, keys, values } from "../../../utils"; import { Atom } from "../../../utils/atoms"; import { Task } from "../../../utils/task"; import { useSubscription } from "../../../utils/use-subscription"; @@ -34,16 +34,18 @@ import { impl } from "../../types/type-mapper-util"; * ``` */ export const useFormHandle = ( - _Schema: FormSchema, + Schema: FormSchema, controller?: FormController ): FormHandle => { const { state, methods } = useFormtsContext(controller); + // TODO: create cache for form handle atoms to avoid memory leaks and redundant computations const stateAtom = useMemo( () => Atom.fuse( ( isTouched, + isChanged, isValid, isValidating, isSubmitting, @@ -51,6 +53,7 @@ export const useFormHandle = ( failedSubmitCount ) => ({ isTouched, + isChanged, isValid, isValidating, isSubmitting, @@ -64,6 +67,18 @@ export const useFormHandle = ( state.failedSubmitCount, state.touched ), + Atom.fuse( + (...fieldsChanged) => fieldsChanged.some(Boolean), + ...values(Schema).map(field => { + const fieldLens = impl(field).__lens; + const initialValue = fieldLens.get(state.initialValues); + const fieldAtom = Atom.entangle(state.values, fieldLens); + return Atom.fuse( + fieldValue => !deepEqual(fieldValue, initialValue), + fieldAtom + ); + }) + ), Atom.fuse(x => values(x).every(err => err == null), state.errors), Atom.fuse(x => keys(x).length > 0, state.validating), state.isSubmitting, @@ -84,6 +99,10 @@ export const useFormHandle = ( return stateAtom.val.isTouched; }, + get isChanged() { + return stateAtom.val.isChanged; + }, + get isValid() { return stateAtom.val.isValid; }, diff --git a/src/core/types/field-handle.ts b/src/core/types/field-handle.ts index ebd9e97..5a2d5ce 100644 --- a/src/core/types/field-handle.ts +++ b/src/core/types/field-handle.ts @@ -38,9 +38,12 @@ type BaseFieldHandle = { /** Field error */ error: null | Err; - /** True if `setValue` `handleChange` or `handleBlur` was called for this field */ + /** True if `setValue` `handleChange` or `handleBlur` were called for this field */ isTouched: boolean; + /** True if the field value is different from its initial value */ + isChanged: boolean; + /** True if the field has no error and none of its children fields have errors */ isValid: boolean; diff --git a/src/core/types/form-handle.ts b/src/core/types/form-handle.ts index 08c012f..3a954ec 100644 --- a/src/core/types/form-handle.ts +++ b/src/core/types/form-handle.ts @@ -14,6 +14,9 @@ export type FormHandle = { /** True if any form field is touched */ isTouched: boolean; + /** True if any form field is changed */ + isChanged: boolean; + /** True if there are no validation errors */ isValid: boolean; diff --git a/src/utils/object.spec.ts b/src/utils/object.spec.ts index f226ccb..fb8bfe2 100644 --- a/src/utils/object.spec.ts +++ b/src/utils/object.spec.ts @@ -1,4 +1,4 @@ -import { deepMerge, get, set, filter } from "./object"; +import { deepMerge, deepEqual, get, set, filter } from "./object"; describe("filter", () => { it("returns object with filtered out properties", () => { @@ -225,3 +225,99 @@ describe("deepMerge", () => { }); }); }); + +describe("deepEqual", () => { + it("should resolve equality for nullable values", () => { + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual({}, undefined)).toBe(false); + expect(deepEqual([], undefined)).toBe(false); + expect(deepEqual(false, undefined)).toBe(false); + expect(deepEqual("", undefined)).toBe(false); + }); + + it("should resolve equality for primitive values", () => { + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(false, true)).toBe(false); + + expect(deepEqual("", "")).toBe(true); + expect(deepEqual("foo", "foo")).toBe(true); + expect(deepEqual("foo", "foo ")).toBe(false); + + expect(deepEqual(1.333, 1.333)).toBe(true); + expect(deepEqual(1.333, 1.332)).toBe(false); + expect(deepEqual(1.333, "1.333")).toBe(false); + expect(deepEqual(-1, 1)).toBe(false); + }); + + it("should resolve equality for 0", () => { + expect(deepEqual(0, 0)).toBe(true); + expect(deepEqual(-0, +0)).toBe(true); + expect(deepEqual(+0, -0)).toBe(true); + }); + + it("should resolve equality for NaN", () => { + expect(deepEqual(NaN, NaN)).toBe(true); + expect(deepEqual(NaN, Infinity)).toBe(false); + expect(deepEqual(NaN, 42)).toBe(false); + }); + + it("should resolve equality for objects", () => { + expect(deepEqual({}, {})).toBe(true); + expect(deepEqual({ foo: 1 }, {})).toBe(false); + expect(deepEqual({}, { foo: 1 })).toBe(false); + expect(deepEqual({ foo: 1 }, { foo: 1 })).toBe(true); + expect(deepEqual({ foo: 1 }, { foo: 2 })).toBe(false); + expect(deepEqual({ foo: 1 }, { foo: 1, bar: 2 })).toBe(false); + expect(deepEqual({ foo: 1, bar: "" }, { foo: 1 })).toBe(false); + expect(deepEqual({ foo: 1 }, { fooo: 1 })).toBe(false); + }); + + it("should resolve equality for nested objects", () => { + expect(deepEqual({ a: {} }, { a: {} })).toBe(true); + expect(deepEqual({ a: {} }, { b: {} })).toBe(false); + expect(deepEqual({ a: { aa: "" } }, { a: {} })).toBe(false); + expect(deepEqual({ a: { aa: "" } }, { a: { aa: "" } })).toBe(true); + expect(deepEqual({ a: { aa: "foo" } }, { a: { aa: "bar" } })).toBe(false); + expect(deepEqual({ a: { aa: "foo" } }, { a: { bb: "foo" } })).toBe(false); + expect(deepEqual({ a: { aa: "foo" } }, { a: { bb: "foo" } })).toBe(false); + expect(deepEqual({ a: { aa: [] } }, { a: { aa: [] } })).toBe(true); + expect(deepEqual({ a: { aa: [1] } }, { a: { aa: [1] } })).toBe(true); + expect(deepEqual({ a: { aa: [1] } }, { a: { aa: [2] } })).toBe(false); + expect(deepEqual({ a: { aa: [1] } }, { a: { aa: [1, 2] } })).toBe(false); + }); + + it("should resolve equality for arrays", () => { + expect(deepEqual([], [])).toBe(true); + expect(deepEqual([1], [1])).toBe(true); + expect(deepEqual([1], [2])).toBe(false); + expect(deepEqual([1, 2], [1])).toBe(false); + expect(deepEqual(["a", "b", "c"], ["b", "c", "a"])).toBe(false); + expect(deepEqual([null], [null])).toBe(true); + expect(deepEqual(Array(10), Array(10))).toBe(true); + expect(deepEqual(Array(10), Array(5))).toBe(false); + }); + + it("should resolve equality for arrays with nested objects", () => { + expect(deepEqual([{ a: 1 }], [{ a: 1 }])).toBe(true); + expect(deepEqual([{ a: 1 }], [{ a: 2 }])).toBe(false); + expect(deepEqual([{ a: 1 }], [{ b: 1 }])).toBe(false); + expect(deepEqual([{ a: {} }], [{ a: {} }])).toBe(true); + expect(deepEqual([{ a: {} }], [{ a: { b: [] } }])).toBe(false); + expect(deepEqual([{ a: { b: [] } }], [{ a: { b: [] } }])).toBe(true); + }); + + it("should resolve equality for Dates", () => { + const timestamp = new Date().getTime(); + + expect(deepEqual(new Date(timestamp), new Date(timestamp))).toBe(true); + expect(deepEqual(new Date(timestamp), new Date(timestamp + 1))).toBe(false); + expect(deepEqual(new Date(timestamp), new Date(NaN))).toBe(false); + expect(deepEqual(new Date(NaN), new Date(NaN))).toBe(true); + }); + + // we do not need to worry about these cases for the purpose of the library + // it("should resolve equality for class instances"); + // it("should resolve equality for symbols"); +}); diff --git a/src/utils/object.ts b/src/utils/object.ts index 1fe9b4c..72c3be9 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -111,3 +111,36 @@ export const deepMerge = ( return acc; }, {} as T); }; + +export const deepEqual = (left: unknown, right: unknown): boolean => { + if (typeof left !== typeof right) { + return false; + } + + if ((!left && !!right) || (!!left && !right)) { + return false; + } + + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length + ? left.every((el, i) => deepEqual(el, right[i])) + : false; + } + + if (isPlainObject(left) && isPlainObject(right)) { + return ( + deepEqual(Object.keys(left), Object.keys(right)) && + deepEqual(Object.values(left), Object.values(right)) + ); + } + + if (left instanceof Date && right instanceof Date) { + return deepEqual(left.getTime(), right.getTime()); + } + + if (Number.isNaN(left) && Number.isNaN(right)) { + return true; + } + + return left === right; +};