Skip to content

Commit

Permalink
feat: implement isChanged flags (#125)
Browse files Browse the repository at this point in the history
* fix: explicitly state children in FormProvider props for react 18 compatibility

* feat: isChanged flag
  • Loading branch information
mixvar authored Sep 5, 2022
1 parent c800bca commit 92515a8
Show file tree
Hide file tree
Showing 9 changed files with 285 additions and 7 deletions.
1 change: 1 addition & 0 deletions src/core/context/form-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const Context = React.createContext<

type FormProviderProps = {
controller: FormController;
children: React.ReactNode;
};

/**
Expand Down
114 changes: 114 additions & 0 deletions src/core/hooks/hooks.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/core/hooks/use-field/use-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ const createFieldHandle = <T, Err>(
);
},

get isChanged() {
return fieldState.val.changed;
},

get error() {
return formState.errors.val[impl(descriptor).__path] ?? null;
},
Expand Down
11 changes: 8 additions & 3 deletions src/core/hooks/use-form-controller/atom-cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deepEqual } from "../../../utils";
import { Atom } from "../../../utils/atoms";
import * as Helpers from "../../helpers";
import { FieldDescriptor } from "../../types/field-descriptor";
Expand All @@ -8,6 +9,7 @@ type FieldPath = string;

export type FieldStateAtom<T> = Atom.Readonly<{
value: T;
changed: boolean;
touched: TouchedValues<T>;
formSubmitted: boolean;
}>;
Expand Down Expand Up @@ -41,15 +43,18 @@ export class FieldStateAtomCache<Values extends object, Err> {
field: FieldDescriptor<T, Err>
): FieldStateAtom<T> {
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,
Expand Down
23 changes: 21 additions & 2 deletions src/core/hooks/use-form-handle/use-form-handle.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,23 +34,26 @@ import { impl } from "../../types/type-mapper-util";
* ```
*/
export const useFormHandle = <Values extends object, Err>(
_Schema: FormSchema<Values, Err>,
Schema: FormSchema<Values, Err>,
controller?: FormController
): FormHandle<Values, Err> => {
const { state, methods } = useFormtsContext<Values, Err>(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,
successfulSubmitCount,
failedSubmitCount
) => ({
isTouched,
isChanged,
isValid,
isValidating,
isSubmitting,
Expand All @@ -64,6 +67,18 @@ export const useFormHandle = <Values extends object, Err>(
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,
Expand All @@ -84,6 +99,10 @@ export const useFormHandle = <Values extends object, Err>(
return stateAtom.val.isTouched;
},

get isChanged() {
return stateAtom.val.isChanged;
},

get isValid() {
return stateAtom.val.isValid;
},
Expand Down
5 changes: 4 additions & 1 deletion src/core/types/field-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ type BaseFieldHandle<T, Err> = {
/** 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;

Expand Down
3 changes: 3 additions & 0 deletions src/core/types/form-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export type FormHandle<Values extends object, Err> = {
/** 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;

Expand Down
Loading

0 comments on commit 92515a8

Please sign in to comment.