From 8c571ab10504ed0c739adf0d8f67d18a118c60af Mon Sep 17 00:00:00 2001 From: iusehooks <46574892+iusehooks@users.noreply.github.com> Date: Sun, 31 Oct 2021 16:32:10 +0100 Subject: [PATCH] keep state persistent when collection and fields are unmounted (#36) (#37) * keep state persistent when collection and fields are unmounted * create the PersistStateOnUnmount component to persist the state on onmounting the component * Persistent state on onmount fixes (#38) * keep state persistent when collection and fields are unmounted * create the PersistStateOnUnmount component to persist the state on onmounting the component * State preseverd on unmount (#39) * keep state persistent when collection and fields are unmounted * create the PersistStateOnUnmount component to persist the state on onmounting the component * update persistent state * remove comment * v3.4.1-alpha.0 * v3.4.1-alpha.1 * PERSISTENTSTATEONUNMOUNT unit test PersistentStateOnUnmount * fix sync validation * 3.4.1-alpha.2 * fix * 3.4.1-alpha.3 * fix radio, checkbox validation message on submit Co-authored-by: Simone Co-authored-by: Antonio Co-authored-by: Antonio Pangallo --- __tests__/FormSyncValidation.spec.js | 15 +- __tests__/Input.spec.js | 97 +++++++++++- __tests__/PersistStateOnUnmount.spec.js | 120 +++++++++++++++ __tests__/helpers/components/CheckBox.jsx | 21 +++ .../components/CollectionDynamicAdded.jsx | 2 +- .../FormWithValidationAfterMounted.jsx | 29 ++++ .../PersistStateOnUnmountHelpers.jsx | 138 ++++++++++++++++++ __tests__/helpers/components/Radio.jsx | 21 +++ docs/PersistStateOnUnmount.mdx | 42 ++++++ examples/helpers/SimpleForm.js | 1 - package-lock.json | 2 +- package.json | 2 +- src/PersistStateOnUnmount.js | 11 ++ src/hooks/useField.js | 17 ++- src/hooks/useForm.js | 8 + src/hooks/useObject.js | 2 +- src/index.d.ts | 1 + src/index.js | 1 + 18 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 __tests__/PersistStateOnUnmount.spec.js create mode 100644 __tests__/helpers/components/CheckBox.jsx create mode 100644 __tests__/helpers/components/FormWithValidationAfterMounted.jsx create mode 100644 __tests__/helpers/components/PersistStateOnUnmountHelpers.jsx create mode 100644 __tests__/helpers/components/Radio.jsx create mode 100644 docs/PersistStateOnUnmount.mdx create mode 100644 src/PersistStateOnUnmount.js diff --git a/__tests__/FormSyncValidation.spec.js b/__tests__/FormSyncValidation.spec.js index 6514349..3dadcef 100644 --- a/__tests__/FormSyncValidation.spec.js +++ b/__tests__/FormSyncValidation.spec.js @@ -1,6 +1,7 @@ import React from "react"; -import { cleanup, fireEvent, act } from "@testing-library/react"; +import { cleanup, fireEvent, act, render } from "@testing-library/react"; +import { FormWithValidationAfterMounted } from "./helpers/components/FormWithValidationAfterMounted"; import Reset from "./helpers/components/Reset"; import { mountForm } from "./helpers/utils/mountForm"; @@ -38,6 +39,18 @@ describe("Component => Form (sync validation)", () => { expect(errorLabel.innerHTML).toBe("Mail not Valid"); }); + it("should synchronously validate a Form if Fields are added after Form is mounted", () => { + const { getByTestId } = render(); + const addBtn = getByTestId("addBtn"); + + act(() => { + fireEvent.click(addBtn); + }); + + const isValid = getByTestId("isValid"); + expect(isValid.innerHTML).toBe("false"); + }); + it("should synchronously validate a Form with touched prop true", () => { const name = "email"; const value = "bebo@test.it"; diff --git a/__tests__/Input.spec.js b/__tests__/Input.spec.js index 206b60c..a135f9b 100644 --- a/__tests__/Input.spec.js +++ b/__tests__/Input.spec.js @@ -9,6 +9,8 @@ import { import userEvent from "@testing-library/user-event"; import { Input } from "./../src"; import InputAsync from "./helpers/components/InputAsync"; +import Radio from "./helpers/components/Radio"; +import CheckBox from "./helpers/components/CheckBox"; import InputSyncValidation from "./helpers/components/InputSyncValidation"; import Submit from "./helpers/components/Submit"; import Reset from "./helpers/components/Reset"; @@ -396,7 +398,22 @@ describe("Component => Input", () => { input.blur(); }); - const errorLabel = getByTestId("errorLabel"); + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel).toBeDefined(); + + act(() => { + input.focus(); + fireEvent.change(input, { target: { value: "1234" } }); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + input.focus(); + fireEvent.change(input, { target: { value: "" } }); + }); + + errorLabel = getByTestId("errorLabel"); expect(errorLabel).toBeDefined(); const reset = getByTestId("reset"); @@ -463,6 +480,84 @@ describe("Component => Input", () => { expect(() => getByTestId("errorLabel")).toThrow(); }); + it("should use sync validator functions to validate a Radio input", () => { + const props = { onReset, onChange }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + const radio = getByTestId("radio"); + + act(() => { + fireEvent.click(submit); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel).toBeDefined(); + + act(() => { + radio.focus(); + fireEvent.click(radio); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + fireEvent.click(reset); + }); + expect(onReset).toHaveBeenCalledWith({}, false); + expect(() => getByTestId("errorLabel")).toThrow(); + }); + + it("should use sync validator functions to validate a Checkbox input", () => { + const props = { onReset, onChange }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + const checkBox = getByTestId("checkbox"); + + act(() => { + fireEvent.click(submit); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel).toBeDefined(); + + act(() => { + checkBox.focus(); + fireEvent.click(checkBox); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + checkBox.focus(); + fireEvent.click(checkBox); + }); + + errorLabel = getByTestId("errorLabel"); + expect(errorLabel).toBeDefined(); + + act(() => { + fireEvent.click(reset); + }); + expect(onReset).toHaveBeenCalledWith({}, false); + expect(() => getByTestId("errorLabel")).toThrow(); + }); + it("should use an async validator function to validate the Input", async () => { const value = "33"; const name = "test"; diff --git a/__tests__/PersistStateOnUnmount.spec.js b/__tests__/PersistStateOnUnmount.spec.js new file mode 100644 index 0000000..df3ade6 --- /dev/null +++ b/__tests__/PersistStateOnUnmount.spec.js @@ -0,0 +1,120 @@ +import React from "react"; +import { cleanup, fireEvent, act, render } from "@testing-library/react"; + +import { + PersistStateOnUnmountHelpers, + initialState +} from "./helpers/components/PersistStateOnUnmountHelpers"; + +const onInit = jest.fn(); +const onChange = jest.fn(); +const onReset = jest.fn(); +const onSubmit = jest.fn(); + +afterEach(cleanup); + +describe("Component => PersistStateOnUnmount", () => { + beforeEach(() => { + onInit.mockClear(); + onChange.mockClear(); + onReset.mockClear(); + onSubmit.mockClear(); + }); + + it("should Collection state persist on unmonuted", () => { + const stateExpected = { + ...initialState, + user: { ...initialState.user, lastname: "hero" } + }; + const props = { onInit, onChange }; + const { getByTestId } = render(); + + const toggleCollection = getByTestId("toggleCollection"); + const lastName = getByTestId("lastname"); + expect(onInit).toHaveBeenCalledWith(initialState, true); + + act(() => { + fireEvent.change(lastName, { target: { value: "hero" } }); + }); + + act(() => { + fireEvent.click(toggleCollection); + }); + + expect(onChange).toHaveBeenCalledWith(stateExpected, true); + + const togglekeepValue = getByTestId("togglekeepValue"); + + act(() => { + fireEvent.click(togglekeepValue); + }); + + expect(onChange).toHaveBeenCalledWith(stateExpected, true); + }); + + it("should Checkbox state persist on unmonuted", () => { + const stateExpected = { ...initialState, other: [, "3"] }; + const props = { onInit, onChange }; + const { getByTestId } = render(); + + const toggleNestedInput = getByTestId("toggleNestedInput"); + const other1 = getByTestId("other1"); + expect(onInit).toHaveBeenCalledWith(initialState, true); + + act(() => { + fireEvent.click(other1); + }); + + expect(onChange).toHaveBeenCalledWith(stateExpected, true); + + act(() => { + fireEvent.click(other1); + }); + + act(() => { + fireEvent.click(toggleNestedInput); + }); + + expect(onChange).toHaveBeenCalledWith(initialState, true); + }); + + it("should Radio state persist on unmonuted", () => { + const stateExpected = { ...initialState, gender: "M" }; + const props = { onInit, onChange }; + const { getByTestId } = render(); + + const toggleNestedRadio = getByTestId("toggleNestedRadio"); + const genderm = getByTestId("genderm"); + expect(onInit).toHaveBeenCalledWith(initialState, true); + + act(() => { + fireEvent.click(genderm); + }); + + act(() => { + fireEvent.click(toggleNestedRadio); + }); + + expect(onChange).toHaveBeenCalledWith(stateExpected, true); + }); + + it("should Select state persist on unmonuted", () => { + const stateExpected = { ...initialState, select: "2" }; + const props = { onInit, onChange }; + const { getByTestId } = render(); + + const toggleSelect = getByTestId("toggleSelect"); + const select = getByTestId("select"); + expect(onInit).toHaveBeenCalledWith(initialState, true); + + act(() => { + fireEvent.change(select, { target: { value: "2" } }); + }); + + act(() => { + fireEvent.click(toggleSelect); + }); + + expect(onChange).toHaveBeenCalledWith(stateExpected, true); + }); +}); diff --git a/__tests__/helpers/components/CheckBox.jsx b/__tests__/helpers/components/CheckBox.jsx new file mode 100644 index 0000000..27dd27c --- /dev/null +++ b/__tests__/helpers/components/CheckBox.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Input, useValidation } from "./../../../src"; + +const required = value => (value ? undefined : "Required"); + +export default function CheckBox({ name = "checkbox", value }) { + const [status, validation] = useValidation([required]); + return ( +
+ + + {status.error && } +
+ ); +} diff --git a/__tests__/helpers/components/CollectionDynamicAdded.jsx b/__tests__/helpers/components/CollectionDynamicAdded.jsx index e412f9c..eefa053 100644 --- a/__tests__/helpers/components/CollectionDynamicAdded.jsx +++ b/__tests__/helpers/components/CollectionDynamicAdded.jsx @@ -18,7 +18,7 @@ export default function CollectionDynamicAdded() { onlyNumberCollection ]); - const [collections, setCollection] = useChildren([]); + const [collections, setCollection] = useChildren(); const index = useRef(0); const addCollection = () => { index.current = index.current + 1; diff --git a/__tests__/helpers/components/FormWithValidationAfterMounted.jsx b/__tests__/helpers/components/FormWithValidationAfterMounted.jsx new file mode 100644 index 0000000..ec18b62 --- /dev/null +++ b/__tests__/helpers/components/FormWithValidationAfterMounted.jsx @@ -0,0 +1,29 @@ +import React, { useState } from "react"; +import Submit from "./Submit"; +import { Form, Input, useValidation } from "./../../../src"; + +const required = value => + value && value.trim() !== "" ? undefined : "Required"; + +export const FormWithValidationAfterMounted = () => { + const [input, setInput] = useState(() => null); + + const [status, inputValidation] = useValidation([required]); + + const onClick = () => { + setInput(); + }; + + return ( + <> +
+ {input} + + + + {status.error && } + + ); +}; diff --git a/__tests__/helpers/components/PersistStateOnUnmountHelpers.jsx b/__tests__/helpers/components/PersistStateOnUnmountHelpers.jsx new file mode 100644 index 0000000..4b23114 --- /dev/null +++ b/__tests__/helpers/components/PersistStateOnUnmountHelpers.jsx @@ -0,0 +1,138 @@ +import React, { useState } from "react"; +import { + Form, + Collection, + Input, + Select, + PersistStateOnUnmount +} from "./../../../src"; +import Submit from "./Submit"; +import Email from "./Email"; +import TextField from "./TextField"; +import Reset from "./Reset"; + +export const initialState = { + keepValue: "keepValue", + user: { + name: "foo", + lastname: "micky", + email: "abc@test.it" + }, + gender: "F", + other: ["1", "3"], + select: "1" +}; + +export const PersistStateOnUnmountHelpers = props => { + const [showCollection, setShowCollection] = useState(true); + const [showShowNestedInput, setShowNestedInput] = useState(true); + const [showShowRadio, setShowRadio] = useState(true); + const [showShowSelect, setShowShowSelect] = useState(true); + + const [showkeepValue, setkeepValue] = useState(true); + + const togglekeepValue = () => { + setkeepValue(prev => !prev); + }; + + const toggleSelect = () => { + setShowShowSelect(prev => !prev); + }; + + const toggleNestedRadio = () => { + setShowRadio(prev => !prev); + }; + + const toggleNestedInput = () => { + setShowNestedInput(prev => !prev); + }; + + const toggleCollection = () => { + setShowCollection(prev => !prev); + }; + + return ( + <> +
+ {showkeepValue && ( + + )} + {showCollection && ( + + + + + + + + )} + {showShowRadio && ( + + + + + )} + + {showShowNestedInput && ( + + + + )} + + + {showShowSelect && ( + + + + )} + + + + + + + + + + + ); +}; diff --git a/__tests__/helpers/components/Radio.jsx b/__tests__/helpers/components/Radio.jsx new file mode 100644 index 0000000..eacbca7 --- /dev/null +++ b/__tests__/helpers/components/Radio.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Input, useValidation } from "./../../../src"; + +const required = value => (value ? undefined : "Required"); + +export default function Radio({ name = "radio", value }) { + const [status, validation] = useValidation([required]); + return ( +
+ + + {status.error && } +
+ ); +} diff --git a/docs/PersistStateOnUnmount.mdx b/docs/PersistStateOnUnmount.mdx new file mode 100644 index 0000000..40699d0 --- /dev/null +++ b/docs/PersistStateOnUnmount.mdx @@ -0,0 +1,42 @@ +--- +name: PersistStateOnUnmount +menu: Components +--- + +import { Playground } from 'docz'; +import { useState } from "react"; +import { Form } from "./helpers/Form"; +import { InputLabel as Input } from "./helpers/InputLabel"; +import { PersistStateOnUnmount, Collection } from './../src'; + +# PersistStateOnUnmount +In `usetheform` if a Field gets unmounted its value within the Form state gets cleared. +Wrap your Field elements between the `` component to preserve the Fields values. + + +## Basic usage + +```javascript + import { Form, PersistStateOnUnmount, Input } from 'usetheform' +``` + + +{() => { + const [visible, toggle] = useState(false); + return ( +
+ + {!visible && ( + + + + + ) + } + + + +
+ )} +} +
\ No newline at end of file diff --git a/examples/helpers/SimpleForm.js b/examples/helpers/SimpleForm.js index 6c11cce..92e84f0 100644 --- a/examples/helpers/SimpleForm.js +++ b/examples/helpers/SimpleForm.js @@ -36,7 +36,6 @@ const asyncTest = value => new Promise((resolve, reject) => { // it could be an API call or any async operation - console.log("asyncTest", value.mailList); setTimeout(() => { if (!value && !value.user && !value.user.mailList) { reject("Empty are not allowed"); diff --git a/package-lock.json b/package-lock.json index 6ac4d2a..4aa5131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "usetheform", - "version": "3.4.0", + "version": "3.4.1-alpha.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 3504a62..25d6bb3 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "usetheform", - "version": "3.4.0", + "version": "3.4.1-alpha.4", "description": "React library for composing declarative forms in React and managing their state.", "main": "./build/index.js", "module": "./build/es/index.js", diff --git a/src/PersistStateOnUnmount.js b/src/PersistStateOnUnmount.js new file mode 100644 index 0000000..13373e0 --- /dev/null +++ b/src/PersistStateOnUnmount.js @@ -0,0 +1,11 @@ +import React, { memo } from "react"; +import { ContextObject as Context, useOwnContext } from "./hooks/useOwnContext"; + +const preventRemoval = () => false; +export const PersistStateOnUnmount = memo(function PersistStateOnUnmount({ + children +}) { + const context = useOwnContext(); + const newContext = { ...context, removeProp: preventRemoval }; + return {children}; +}); diff --git a/src/hooks/useField.js b/src/hooks/useField.js index dfd5384..df17987 100755 --- a/src/hooks/useField.js +++ b/src/hooks/useField.js @@ -104,6 +104,7 @@ export function useField(props) { const reset = useCallback(formState => { valueFieldLastAsyncCheck.current = null; valueFieldLastSyncCheck.current = null; + onFirstBlur.current = false; switch (type) { case "number": @@ -246,6 +247,8 @@ export function useField(props) { const newValue = applyReducers(val, initialValue, formState.current); context.initProp(nameProp.current, newValue, val); + } else if (validators.length > 0 && context.stillMounted()) { + context.runSyncValidation(); } return () => { @@ -271,6 +274,7 @@ export function useField(props) { }, true ); + context.unRegisterReset(nameProp.current); if (context.type === "array") { context.removeIndex(uniqueIDarrayContext); @@ -304,7 +308,8 @@ export function useField(props) { context.formStatus !== STATUS.ON_INIT_ASYNC ) { const firstTimeCheck = valueFieldLastSyncCheck.current === null; - const onlyShowOnSubmit = type === "radio" || type === "checkbox"; + const isRadioOrCheckBox = type === "radio" || type === "checkbox"; + const onlyShowOnSubmit = isRadioOrCheckBox; const isCustomCmp = type === "custom"; const forceOnBlur = type === "select" && multiple; @@ -315,14 +320,18 @@ export function useField(props) { (isCustomCmp && touched && onSyncBlurState) || context.formStatus === STATUS.ON_SUBMIT || (!onlyShowOnSubmit && - ((touched && onSyncBlurState) || + ((touched && onFirstBlur.current) || (!touched && forceOnBlur && (onSyncBlurState || firstTimeCheck)) || (!touched && !forceOnBlur))) || (onlyShowOnSubmit && onSyncFocusState)) ) { - valueFieldLastSyncCheck.current = valueField.current; + if (isRadioOrCheckBox) { + valueFieldLastSyncCheck.current = checkedField.current; + } else { + valueFieldLastSyncCheck.current = valueField.current; + } onValidation( validationObj.current.checks, @@ -368,8 +377,10 @@ export function useField(props) { context.formStatus ]); + const onFirstBlur = useRef(false); const onBlur = useCallback(e => { e.persist(); + onFirstBlur.current = true; setAsyncOnBlur(true); setSyncOnBlur(true); customBlur(e); diff --git a/src/hooks/useForm.js b/src/hooks/useForm.js index bd61f90..333463f 100755 --- a/src/hooks/useForm.js +++ b/src/hooks/useForm.js @@ -327,6 +327,13 @@ export function useForm({ } }, []); + const runSyncValidation = useCallback(() => { + const isValid = isFormValid(validators.current, stateRef.current.state); + + stateRef.current.isValid !== isValid && + dispatchFormState({ ...stateRef.current, isValid }); + }, []); + const runAsyncValidation = useCallback(({ start, end }) => { if (start) { const status = STATUS.ON_RUN_ASYNC; @@ -457,6 +464,7 @@ export function useForm({ updateRegisteredField, registerAsyncInitValidation, runAsyncValidation, + runSyncValidation, dispatchNewState, changeProp, initProp, diff --git a/src/hooks/useObject.js b/src/hooks/useObject.js index 55e61b6..96c4df7 100755 --- a/src/hooks/useObject.js +++ b/src/hooks/useObject.js @@ -379,7 +379,6 @@ export function useObject(props) { ); } // ----- remove validators inerithed by children ----- // - context.removeProp( nameProp.current, { @@ -422,6 +421,7 @@ export function useObject(props) { formState: context.formState, // pass the global form state down formStatus: context.formStatus, // pass the global form status down runAsyncValidation: context.runAsyncValidation, + runSyncValidation: context.runSyncValidation, unRegisterField, updateRegisteredField, registerAsyncInitValidation, diff --git a/src/index.d.ts b/src/index.d.ts index 2bc0f4f..40fbc53 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -5,6 +5,7 @@ declare module "usetheform" { export const Select: any; export const TextArea: any; export const Collection: any; + export const PersistStateOnUnmount: any; export const FormContext: any; export const getValueByPath: any; export const STATUS: any; diff --git a/src/index.js b/src/index.js index 3fb0a88..ae09204 100755 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ export { Input } from "./Input"; export { Select } from "./Select"; export { TextArea } from "./TextArea"; export { Collection } from "./Collection"; +export { PersistStateOnUnmount } from "./PersistStateOnUnmount"; export { FormContext } from "./FormContext"; export { getValueByPath } from "./utils/formUtils"; export { STATUS } from "./utils/constants";