diff --git a/.babelrc.js b/.babelrc.js index 2e9679c..5dceda7 100755 --- a/.babelrc.js +++ b/.babelrc.js @@ -18,6 +18,10 @@ if (process.env.NODE_ENV === "test") { plugins.push("@babel/transform-modules-commonjs"); } +if (process.env.NODE_ENV === "development") { + presets = ["@babel/preset-react"]; +} + module.exports = { presets, plugins diff --git a/.eslintignore b/.eslintignore index 9dfcf1a..7bebc83 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ -**/build/** +**/build/** +**/dev/** **/node_modules/** \ No newline at end of file diff --git a/.gitignore b/.gitignore index b8b4f5a..cc73d56 100755 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ node_modules .DS_Store dist build +dev coverage _config.yml diff --git a/README.md b/README.md index 1218bee..6b771fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Usetheform Logo -Usetheform is a React library for composing declarative forms and managing their state. It uses the Context API and React Hooks. I does not depend on any libray like redux or others. +Usetheform is a React library for composing declarative forms and managing their state. It uses the Context API and React Hooks. I does not depend on any library like redux or others. - [Documentation](https://iusehooks.github.io/usetheform/) - [Installation](#Installation) @@ -21,6 +21,7 @@ npm install --save usetheform # CodeSandbox Examples +- Shopping Cart: [Sandbox](https://codesandbox.io/s/shopping-cart-97y5k) - Examples: Slider, Select, Collections etc..: [Sandbox](https://codesandbox.io/s/formexample2-mmcjs) - Various Implementation: [Sandbox](https://codesandbox.io/s/035l4l75ln) - Wizard: [Sandbox](https://codesandbox.io/s/v680xok7k7) diff --git a/__tests__/AsyncValidationFormStrictMode.spec.js b/__tests__/AsyncValidationFormStrictMode.spec.js new file mode 100644 index 0000000..ac5f1de --- /dev/null +++ b/__tests__/AsyncValidationFormStrictMode.spec.js @@ -0,0 +1,188 @@ +import React from "react"; +import { + render, + cleanup, + fireEvent, + waitForElement, + act +} from "@testing-library/react"; + +import { + SimpleFormWithAsyncStrictMode, + expectedInitialState +} from "./helpers/components/SimpleFormWithAsync"; + +const mountForm = ({ props = {} } = {}) => + render( + + + + ); + +const onInit = jest.fn(state => state); +const onChange = jest.fn(state => state); +const onReset = jest.fn(state => state); +const onSubmit = jest.fn(state => state); + +afterEach(cleanup); + +describe("Async Validation Form StrictMode => Async Validation", () => { + beforeEach(() => { + onInit.mockClear(); + onChange.mockClear(); + onReset.mockClear(); + onSubmit.mockClear(); + }); + + it("should run Async validators at Form initialization time", async () => { + const props = { onInit, onSubmit, onChange, onReset }; + + const { getByTestId } = mountForm({ props }); + + const submit = getByTestId("submit"); + + const asyncStartUsername = await waitForElement(() => + getByTestId("asyncStartUsername") + ); + expect(asyncStartUsername).toBeDefined(); + + const asyncStartCity = await waitForElement(() => + getByTestId("asyncStartCity") + ); + expect(asyncStartCity).toBeDefined(); + + const asyncErrorDetails = await waitForElement(() => + getByTestId("asyncErrorDetails") + ); + expect(asyncErrorDetails).toBeDefined(); + + expect(onInit).toHaveReturnedWith(expectedInitialState); + expect(submit.disabled).toBe(true); + }); + + it("should run Async validators on fields or Collections changes", async () => { + const props = { onInit, onSubmit, onChange, onReset }; + + const { getByTestId } = mountForm({ props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addInputs = getByTestId("addInput"); + const removeInputs = getByTestId("removeInput"); + + const asyncErrorDetails = await waitForElement(() => + getByTestId("asyncErrorDetails") + ); + expect(asyncErrorDetails).toBeDefined(); + + const details = getByTestId("details"); + + act(() => { + details.focus(); + fireEvent.change(details, { target: { value: "3331234567" } }); + details.blur(); + }); + + expect(details.value).toBe("3331234567"); + + const asyncSuccessDetails = await waitForElement(() => + getByTestId("asyncSuccessDetails") + ); + + expect(asyncSuccessDetails).toBeDefined(); + + const email = getByTestId("email"); + + act(() => { + email.focus(); + fireEvent.change(email, { target: { value: "test@live.it" } }); + email.blur(); + }); + + expect(email.value).toBe("test@live.it"); + expect(submit.disabled).toBe(false); + + fireEvent.click(submit); + + let asyncErrorCollection = await waitForElement(() => + getByTestId("asyncError") + ); + + expect(asyncErrorCollection).toBeDefined(); + + const submittedCounter = getByTestId("submittedCounter"); + expect(submittedCounter.textContent).toBe("0"); + + fireEvent.click(addInputs); + fireEvent.click(addInputs); + fireEvent.click(submit); + + const asyncSuccessCollection = await waitForElement(() => + getByTestId("asyncSuccess") + ); + + expect(asyncSuccessCollection).toBeDefined(); + expect(submittedCounter.textContent).toBe("1"); + + fireEvent.click(removeInputs); + fireEvent.click(submit); + + asyncErrorCollection = await waitForElement(() => + getByTestId("asyncError") + ); + + expect(asyncErrorCollection).toBeDefined(); + + fireEvent.click(removeInputs); + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith(expectedInitialState); + + const asyncNotStartedYetDetails = getByTestId("asyncNotStartedYetDetails"); + expect(asyncNotStartedYetDetails).toBeDefined(); + + const asyncNotStartedYetCity = getByTestId("asyncNotStartedYetCity"); + expect(asyncNotStartedYetCity).toBeDefined(); + + const asyncNotStartedYetUsername = getByTestId( + "asyncNotStartedYetUsername" + ); + expect(asyncNotStartedYetUsername).toBeDefined(); + + act(() => { + details.focus(); + details.blur(); + }); + + const asyncStartDetailsAfterReset = await waitForElement(() => + getByTestId("asyncStartDetails") + ); + expect(asyncStartDetailsAfterReset).toBeDefined(); + + const asyncErrorDetailsAfterReset = await waitForElement(() => + getByTestId("asyncErrorDetails") + ); + expect(asyncErrorDetailsAfterReset).toBeDefined(); + + const username = getByTestId("username"); + const city = getByTestId("city"); + act(() => { + username.focus(); + username.blur(); + city.focus(); + city.blur(); + }); + + const [ + asyncStartUsernameAfterReset, + asyncStartCityAfterReset + ] = await waitForElement(() => + Promise.all([ + getByTestId("asyncStartUsername"), + getByTestId("asyncStartCity") + ]) + ); + expect(asyncStartUsernameAfterReset).toBeDefined(); + expect(asyncStartCityAfterReset).toBeDefined(); + }); +}); diff --git a/__tests__/Collection.spec.js b/__tests__/Collection.spec.js index bc5a269..baf0370 100644 --- a/__tests__/Collection.spec.js +++ b/__tests__/Collection.spec.js @@ -1,8 +1,14 @@ import React from "react"; -import { render, fireEvent, waitForElement } from "@testing-library/react"; +import { + render, + fireEvent, + waitForElement, + cleanup +} from "@testing-library/react"; import Form, { Input, Collection } from "./../src"; +import { CollectionDynamicCart } from "./helpers/components/CollectionDynamicField"; import CollectionValidation from "./helpers/components/CollectionValidation"; import CollectionAsyncValidation from "./helpers/components/CollectionAsyncValidation"; import CollectionArrayNested, { @@ -20,7 +26,7 @@ import CollectionObjectNested, { } from "./helpers/components/CollectionObjectNested"; import Reset from "./helpers/components/Reset"; - +import Submit from "./helpers/components/Submit"; import AgeRange from "./helpers/components/AgeRange"; const mountForm = ({ props = {}, children } = {}) => @@ -31,11 +37,13 @@ const name = "user"; const userName = "username"; const typeInput = "text"; -const onInit = jest.fn(state => state); +const onInit = jest.fn(); const onChange = jest.fn(); const onReset = jest.fn(); const onSubmit = jest.fn(); +afterEach(cleanup); + describe("Component => Collection", () => { beforeEach(() => { onInit.mockClear(); @@ -59,7 +67,10 @@ describe("Component => Collection", () => { ]; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: { [userName]: value } }); + expect(onInit).toHaveBeenCalledWith( + { [name]: { [userName]: value } }, + true + ); }); it("should render a Collection of type array with an inial value", () => { @@ -73,7 +84,7 @@ describe("Component => Collection", () => { ]; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: [value] }); + expect(onInit).toHaveBeenCalledWith({ [name]: [value] }, true); }); it("should apply reducer functions to reduce the Collection value", () => { @@ -84,31 +95,48 @@ describe("Component => Collection", () => { const start = getByTestId("start"); const end = getByTestId("end"); - expect(onInit).toHaveReturnedWith({ ageRange: { start: 18, end: 65 } }); + expect(onInit).toHaveBeenCalledWith( + { ageRange: { start: 18, end: 65 } }, + true + ); + onChange.mockClear(); fireEvent.change(start, { target: { value: 50 } }); fireEvent.change(end, { target: { value: 80 } }); - expect(onChange).toHaveBeenCalledWith({ ageRange: { start: 50, end: 80 } }); + expect(onChange).toHaveBeenCalledWith( + { ageRange: { start: 50, end: 80 } }, + true + ); + onChange.mockClear(); fireEvent.change(start, { target: { value: 81 } }); fireEvent.change(end, { target: { value: 80 } }); - expect(onChange).toHaveBeenCalledWith({ ageRange: { start: 80, end: 80 } }); + expect(onChange).toHaveBeenCalledWith( + { ageRange: { start: 80, end: 80 } }, + true + ); + onChange.mockClear(); fireEvent.change(start, { target: { value: 18 } }); fireEvent.change(end, { target: { value: 90 } }); - expect(onChange).toHaveBeenCalledWith({ ageRange: { start: 18, end: 90 } }); + expect(onChange).toHaveBeenCalledWith( + { ageRange: { start: 18, end: 90 } }, + true + ); + onChange.mockClear(); fireEvent.change(start, { target: { value: 18 } }); fireEvent.change(end, { target: { value: 16 } }); - expect(onChange).toHaveBeenCalledWith({ ageRange: { start: 18, end: 18 } }); + expect(onChange).toHaveBeenCalledWith( + { ageRange: { start: 18, end: 18 } }, + true + ); }); it("should show an error label if Collection is not valid due to sync validator", () => { const children = [ , - + ]; const { getByTestId } = mountForm({ children }); const submit = getByTestId("submit"); @@ -118,6 +146,34 @@ describe("Component => Collection", () => { expect(errorLabel).toBeDefined(); }); + it("should trigger onChange, onInit, onReset with flag 'isValid' false if Collection is not valid due to sync validator", () => { + const props = { onChange, onInit: jest.fn(), onReset }; + const validator = val => (val && val === "Antonio" ? undefined : "error"); + const children = [ + + + , + , + + ]; + const { getByTestId } = mountForm({ children, props }); + const name = getByTestId("name"); + const reset = getByTestId("reset"); + + expect(props.onInit).toHaveBeenCalledWith({}, false); + + fireEvent.change(name, { target: { value: "Toto" } }); + expect(onChange).toHaveBeenCalledWith({ user: { name: "Toto" } }, false); + + fireEvent.click(reset); + expect(onReset).toHaveBeenCalledWith({}, false); + }); + it("should reset the Collection state", () => { const children = [, ]; const { getByTestId, getAllByTestId } = mountForm({ children }); @@ -147,11 +203,11 @@ describe("Component => Collection", () => { const { getByTestId } = mountForm({ props, children }); const user = getByTestId("user"); fireEvent.change(user, { target: { value: "foo" } }); - expect(onChange).toHaveBeenCalledWith({ user: { name: "foo" } }); + expect(onChange).toHaveBeenCalledWith({ user: { name: "foo" } }, true); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ user: { name: "test" } }); + expect(onReset).toHaveBeenCalledWith({ user: { name: "test" } }, true); }); it("should reduce a Collection of type object value with the given reducer function", () => { @@ -184,7 +240,7 @@ describe("Component => Collection", () => { const { getByTestId } = mountForm({ props, children }); expect(jestReducer).toHaveBeenCalledWith({ name: "foo" }, {}); - expect(onInit).toHaveReturnedWith({ user: { name: "foo" } }); + expect(onInit).toHaveBeenCalledWith({ user: { name: "foo" } }, true); jestReducer.mockReset(); const user = getByTestId("user"); @@ -229,12 +285,11 @@ describe("Component => Collection", () => { it("should show an error label if Collection is not valid due to async validator", async () => { const children = [ , - + ]; const { getByTestId } = mountForm({ children }); const submit = getByTestId("submit"); + const addInput = getByTestId("addInput"); fireEvent.click(submit); const asyncStart = await waitForElement(() => getByTestId("asyncStart")); @@ -242,35 +297,50 @@ describe("Component => Collection", () => { const asyncError = await waitForElement(() => getByTestId("asyncError")); expect(asyncError).toBeDefined(); + + fireEvent.click(addInput); + fireEvent.click(addInput); + fireEvent.click(submit); + const asyncSuccess = await waitForElement(() => + getByTestId("asyncSuccess") + ); + expect(asyncSuccess).toBeDefined(); }); it("should render a nested array Collection with initial value passed as prop", () => { const props = { onInit, onSubmit, onChange, onReset }; const children = [ , - , + , ]; const { getByTestId } = mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ arrayNested: initialValueNested }); + expect(onInit).toHaveBeenCalledWith( + { arrayNested: initialValueNested }, + true + ); [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(dataTestid => { const input = getByTestId(`${dataTestid}`); fireEvent.change(input, { target: { value: `input_${dataTestid}` } }); }); - expect(onChange).toHaveBeenCalledWith({ - arrayNested: expectedValueArrayNested - }); + expect(onChange).toHaveBeenCalledWith( + { + arrayNested: expectedValueArrayNested + }, + true + ); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - arrayNested: initialValueNested - }); + expect(onReset).toHaveBeenCalledWith( + { + arrayNested: initialValueNested + }, + true + ); const submit = getByTestId("submit"); fireEvent.click(submit); @@ -284,29 +354,36 @@ describe("Component => Collection", () => { const props = { onInit, onSubmit, onChange, onReset }; const children = [ , - , + , ]; const { getByTestId } = mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ arrayNested: initialValueNested }); + expect(onInit).toHaveBeenCalledWith( + { arrayNested: initialValueNested }, + true + ); [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(dataTestid => { const input = getByTestId(`${dataTestid}`); fireEvent.change(input, { target: { value: `input_${dataTestid}` } }); }); - expect(onChange).toHaveBeenCalledWith({ - arrayNested: expectedValueArrayNested - }); + expect(onChange).toHaveBeenCalledWith( + { + arrayNested: expectedValueArrayNested + }, + true + ); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - arrayNested: initialValueNested - }); + expect(onReset).toHaveBeenCalledWith( + { + arrayNested: initialValueNested + }, + true + ); const submit = getByTestId("submit"); fireEvent.click(submit); @@ -317,36 +394,36 @@ describe("Component => Collection", () => { }); it("should render a reduced nested array Collection with initial value passed by the inputs field", () => { - const props = { onInit, onSubmit, onChange, onReset }; + const props = { onInit }; const children = [ , - , + , ]; mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ - arrayNested: expectedValueArrayNestedReduced - }); + expect(onInit).toHaveBeenCalledWith( + { + arrayNested: expectedValueArrayNestedReduced + }, + true + ); }); it("should render a nested object Collection with initial valued passed as prop of the Collection", () => { - const props = { onInit, onSubmit, onChange, onReset }; + const props = { onInit, onSubmit, onReset }; const children = [ , - , + , ]; const { getByTestId } = mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ lv1: initialValueObjNested }); + expect(onInit).toHaveBeenCalledWith({ lv1: initialValueObjNested }, true); [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(dataTestid => { const input = getByTestId(`${dataTestid}`); + expect(input.value).toBe(`${dataTestid}`); fireEvent.change(input, { target: { value: `${dataTestid}_1` } }); }); @@ -359,23 +436,24 @@ describe("Component => Collection", () => { const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - lv1: initialValueObjNested - }); + expect(onReset).toHaveBeenCalledWith( + { + lv1: initialValueObjNested + }, + true + ); }); it("should render a nested object Collection with initial value passed by the input fields", () => { const props = { onInit, onSubmit, onChange, onReset }; const children = [ , - , + , ]; const { getByTestId } = mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ lv1: initialValueObjNested }); + expect(onInit).toHaveBeenCalledWith({ lv1: initialValueObjNested }, true); [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(dataTestid => { const input = getByTestId(`${dataTestid}`); @@ -389,26 +467,106 @@ describe("Component => Collection", () => { true ); + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(dataTestid => { + const input = getByTestId(`${dataTestid}`); + expect(input.value).toBe(`${dataTestid}_1`); + }); + const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - lv1: initialValueObjNested - }); + expect(onReset).toHaveBeenCalledWith( + { + lv1: initialValueObjNested + }, + true + ); }); it("should render a reduced nested object Collection with initial value passed by the inputs field", () => { const props = { onInit, onSubmit, onChange, onReset }; const children = [ , - , + , ]; mountForm({ children, props }); - expect(onInit).toHaveReturnedWith({ - lv1: expectedValueObjNested + expect(onInit).toHaveBeenCalledWith( + { + lv1: expectedValueObjNested + }, + true + ); + }); + + it("should run reducer functions on Collection fields removal", () => { + const props = { onSubmit, onChange, onReset }; + const reducer = jest.fn(value => { + const { items = [] } = value.list; + const result = items.reduce((acc, val) => { + acc += val; + return acc; + }, 0); + const list = { ...value.list, result }; + return { ...value, list }; }); + const children = [ + , + , + + ]; + const { getByTestId } = mountForm({ children, props }); + expect(reducer).toHaveBeenCalled(); + expect(reducer).toHaveReturnedWith({ list: { result: 0 } }); + + const addInput = getByTestId("addInput"); + const removeInput = getByTestId("removeInput"); + + fireEvent.click(addInput); + expect(reducer).toHaveBeenCalled(); + expect(reducer).toHaveReturnedWith({ list: { items: [1], result: 1 } }); + + fireEvent.click(addInput); + expect(reducer).toHaveBeenCalled(); + expect(reducer).toHaveReturnedWith({ list: { items: [1, 2], result: 3 } }); + + fireEvent.click(removeInput); + expect(reducer).toHaveBeenCalled(); + expect(reducer).toHaveReturnedWith({ list: { items: [1], result: 1 } }); + + fireEvent.click(removeInput); + expect(reducer).toHaveBeenCalled(); + expect(reducer).toHaveReturnedWith({ list: { result: 0 } }); + }); + + it("should throw an error if the the prop 'index' is not an integer", () => { + const originalError = console.error; + console.error = jest.fn(); + let children = [ + + + + ]; + expect(() => mountForm({ children })).toThrowError(/The prop "index"/i); + + children = [ + + + + + + ]; + expect(() => mountForm({ children })).toThrowError(/The prop "index"/i); + + children = [ + + + + + + ]; + expect(() => mountForm({ children })).toThrowError(/The prop "index"/i); + + console.error = originalError; }); }); diff --git a/__tests__/CollectionsStrictMode.spec.js b/__tests__/CollectionsStrictMode.spec.js new file mode 100644 index 0000000..e41114f --- /dev/null +++ b/__tests__/CollectionsStrictMode.spec.js @@ -0,0 +1,650 @@ +import React from "react"; +import { render, cleanup, fireEvent } from "@testing-library/react"; + +import Form from "./../src"; +import { + CollectionDynamicField, + CollectionNestedDynamicField, + CollectionNestedRadioCheckbox, + CollectionNestedRandomPosition, + CollectionNestedRandomPositionCollection +} from "./helpers/components/CollectionDynamicField"; + +import { CollectionObjectNestedRadios } from "./helpers/components/CollectionObjectNested"; + +import Reset from "./helpers/components/Reset"; + +const mountForm = ({ props = {}, children } = {}) => + render( + +
{children}
+
+ ); + +const onInit = jest.fn(state => state); +const onChange = jest.fn(state => state); +const onReset = jest.fn(state => state); +const onSubmit = jest.fn(state => state); + +afterEach(cleanup); + +describe("Collections Nested StrictMode => Collections", () => { + beforeEach(() => { + onInit.mockClear(); + onChange.mockClear(); + onReset.mockClear(); + onSubmit.mockClear(); + }); + + it("should add/remove input fields dynamically", () => { + const props = { onInit, onSubmit, onChange, onReset }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addInputs = getByTestId("addInput"); + const removeInputs = getByTestId("removeInput"); + + expect(onInit).toHaveReturnedWith({}); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamic: [1] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamic: [1, 2] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamic: [1, 2, 3, 4] }); + + for (let i = 1; i <= 4; i++) { + const input = getByTestId(`input_${i}`); + expect(input.value).toBe(`${i}`); + fireEvent.change(input, { target: { value: `input_${i}` } }); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ + dynamic: ["input_1", "input_2", "input_3", "input_4"] + }); + + for (let i = 1; i <= 4; i++) { + const input = getByTestId(`input_${i}`); + expect(input.value).toBe(`input_${i}`); + } + + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith({ + dynamic: [1, 2, 3, 4] + }); + + for (let i = 1; i <= 4; i++) { + fireEvent.click(removeInputs); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({}); + }); + + it("should add/remove array collections", () => { + const props = { onInit, onSubmit, onChange, onReset }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addInputs = getByTestId("addInput"); + const removeInputs = getByTestId("removeInput"); + const addCollection = getByTestId("addCollection"); + const removeCollection = getByTestId("removeCollection"); + + expect(onInit).toHaveReturnedWith({}); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicNested: [[1]] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicNested: [[1, 2]] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicNested: [[1, 2, 3, 4]] }); + + for (let i = 1; i <= 4; i++) { + const input = getByTestId(`input_${i}`); + fireEvent.change(input, { target: { value: `input_${i}` } }); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ + dynamicNested: [["input_1", "input_2", "input_3", "input_4"]] + }); + + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith({ + dynamicNested: [[1, 2, 3, 4]] + }); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ dynamicNested: [[1, 2, 3, 4, [1]]] }); + + for (let i = 1; i <= 4; i++) { + fireEvent.click(removeInputs); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ dynamicNested: [[[1]]] }); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicNested: [[[1], [2]]] + }); + + onChange.mockClear(); + fireEvent.click(removeCollection); + fireEvent.click(removeCollection); + expect(onChange).toHaveReturnedWith({}); + }); + + it("should add/remove array collections of radio and checkboxes", () => { + const props = { onInit, onSubmit, onChange, onReset }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addInputs = getByTestId("addInput"); + const removeInputs = getByTestId("removeInput"); + const addCollection = getByTestId("addCollection"); + const removeCollection = getByTestId("removeCollection"); + + expect(onInit).toHaveReturnedWith({}); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicRadioCheckbox: [[1]] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicRadioCheckbox: [[1, 2]] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ + dynamicRadioCheckbox: [[1, 2, 3, 4]] + }); + + for (let i = 1; i <= 4; i++) { + const checkbox = getByTestId(`checkbox_${i}`); + expect(checkbox.checked).toBe(true); + fireEvent.click(checkbox); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({}); + + for (let i = 1; i <= 4; i++) { + const checkbox = getByTestId(`checkbox_${i}`); + expect(checkbox.checked).toBe(false); + } + + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith({ + dynamicRadioCheckbox: [[1, 2, 3, 4]] + }); + + for (let i = 1; i <= 4; i++) { + const checkbox = getByTestId(`checkbox_${i}`); + expect(checkbox.checked).toBe(true); + } + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRadioCheckbox: [[1, 2, 3, 4, [1]]] + }); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRadioCheckbox: [[1, 2, 3, 4, [1], [2]]] + }); + + for (let i = 1; i <= 4; i++) { + fireEvent.click(removeInputs); + } + + onSubmit.mockClear(); + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ dynamicRadioCheckbox: [[[1], [2]]] }); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRadioCheckbox: [[[1], [2], [3]]] + }); + + onChange.mockClear(); + for (let i = 1; i <= 3; i++) { + fireEvent.click(removeCollection); + } + expect(onChange).toHaveReturnedWith({}); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRadioCheckbox: [[[4]]] + }); + + onChange.mockClear(); + fireEvent.click(removeCollection); + expect(onChange).toHaveReturnedWith({}); + }); + + it("should add/remove array inputs at random positions", () => { + const props = { onInit, onSubmit, onChange, onReset }; + const myself = { current: null }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addInputs = getByTestId("addInput"); + const removeInputs = getByTestId("removeInput"); + + expect(onInit).toHaveReturnedWith({}); + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ dynamicRandomPosition: [1] }); + + onChange.mockClear(); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + onChange.mockClear(); + fireEvent.click(addInputs); + fireEvent.click(addInputs); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + const currentState = [...myself.current.getInnerState()]; + myself.current.getInnerState().forEach((val, index) => { + const input = getByTestId(`input_${val}`); + myself.current.setValue(index, `input_${val}`); + + fireEvent.change(input, { target: { value: `input_${val}` } }); + }); + + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + onChange.mockClear(); + fireEvent.click(reset); + myself.current.setInnerState(currentState); + expect(onReset).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + onChange.mockClear(); + fireEvent.click(removeInputs); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + onChange.mockClear(); + fireEvent.click(removeInputs); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: myself.current.getInnerState() + }); + + onChange.mockClear(); + [...myself.current.getInnerState()].forEach(() => { + fireEvent.click(removeInputs); + }); + + expect(onChange).toHaveReturnedWith({}); + }); + + it("should add/remove array collections of inputs at random positions", () => { + const props = { onInit, onSubmit, onChange, onReset }; + const myself = { current: null }; + const children = [ + , + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + const addCollection = getByTestId("addCollection"); + const removeCollection = getByTestId("removeCollection"); + + expect(onInit).toHaveReturnedWith({}); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + onChange.mockClear(); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + onChange.mockClear(); + fireEvent.click(addCollection); + fireEvent.click(addCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + const currentState = [...myself.current.getInnerState()]; + myself.current.getInnerState().forEach((val, index) => { + const input = getByTestId(`input_${val}`); + myself.current.setValue(index, [`input_${val}`]); + + fireEvent.change(input, { target: { value: `input_${val}` } }); + }); + + fireEvent.click(submit); + expect(onSubmit).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + fireEvent.click(reset); + myself.current.setInnerState(currentState); + expect(onReset).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + onChange.mockClear(); + fireEvent.click(removeCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + onChange.mockClear(); + fireEvent.click(removeCollection); + expect(onChange).toHaveReturnedWith({ + dynamicRandomPosition: [myself.current.getInnerState()] + }); + + onChange.mockClear(); + [...myself.current.getInnerState()].forEach(() => { + fireEvent.click(removeCollection); + }); + + expect(onChange).toHaveReturnedWith({}); + }); + + it("should reset a nested collection of radio buttons to its initial state - 1", () => { + const initialState = { + lv1: { + 1: "2", + lv2: { 2: "3", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }; + const props = { onInit, onChange, onReset, initialState }; + const children = [ + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + const reset = getByTestId("reset"); + + const radio1lv1 = getByTestId("1"); + const radio2lv1 = getByTestId("2"); + + const radio1lv2 = getByTestId("3"); + const radio2lv2 = getByTestId("4"); + + const radio1lv3 = getByTestId("5"); + const radio2lv3 = getByTestId("6"); + + const radio1lv4 = getByTestId("7"); + const radio2lv4 = getByTestId("8"); + + const radio1lv5 = getByTestId("9"); + const radio2lv5 = getByTestId("10"); + + expect(onInit).toHaveReturnedWith(initialState); + expect(radio2lv1.checked).toBe(true); + expect(radio1lv2.checked).toBe(true); + expect(radio2lv3.checked).toBe(true); + expect(radio1lv4.checked).toBe(true); + expect(radio1lv5.checked).toBe(true); + + fireEvent.click(radio1lv1); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "3", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv1.checked).toBe(true); + expect(radio2lv1.checked).toBe(false); + + fireEvent.click(radio2lv2); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv2.checked).toBe(false); + expect(radio2lv2.checked).toBe(true); + + fireEvent.click(radio1lv3); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv3.checked).toBe(true); + expect(radio2lv3.checked).toBe(false); + + fireEvent.click(radio2lv4); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "8", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv4.checked).toBe(false); + expect(radio2lv4.checked).toBe(true); + + fireEvent.click(radio2lv5); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "8", lv5: { 5: "10" } } } } + } + }); + expect(radio1lv5.checked).toBe(false); + expect(radio2lv5.checked).toBe(true); + + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith(initialState); + expect(radio1lv1.checked).toBe(false); + expect(radio2lv1.checked).toBe(true); + + expect(radio1lv2.checked).toBe(true); + expect(radio2lv2.checked).toBe(false); + + expect(radio1lv3.checked).toBe(false); + expect(radio2lv3.checked).toBe(true); + + expect(radio1lv4.checked).toBe(true); + expect(radio2lv4.checked).toBe(false); + + expect(radio1lv5.checked).toBe(true); + expect(radio2lv5.checked).toBe(false); + }); + + it("should reset a nested collection of radio buttons to its initial state - 2", () => { + const initialState = { + lv1: { + 1: "2", + lv2: { 2: "3", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }; + const props = { onInit, onChange, onReset }; + const children = [ + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + const reset = getByTestId("reset"); + + const radio1lv1 = getByTestId("1"); + const radio2lv1 = getByTestId("2"); + + const radio1lv2 = getByTestId("3"); + const radio2lv2 = getByTestId("4"); + + const radio1lv3 = getByTestId("5"); + const radio2lv3 = getByTestId("6"); + + const radio1lv4 = getByTestId("7"); + const radio2lv4 = getByTestId("8"); + + const radio1lv5 = getByTestId("9"); + const radio2lv5 = getByTestId("10"); + + expect(onInit).toHaveReturnedWith(initialState); + expect(radio2lv1.checked).toBe(true); + expect(radio1lv2.checked).toBe(true); + expect(radio2lv3.checked).toBe(true); + expect(radio1lv4.checked).toBe(true); + expect(radio1lv5.checked).toBe(true); + + fireEvent.click(radio1lv1); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "3", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv1.checked).toBe(true); + expect(radio2lv1.checked).toBe(false); + + fireEvent.click(radio2lv2); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "6", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv2.checked).toBe(false); + expect(radio2lv2.checked).toBe(true); + + fireEvent.click(radio1lv3); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "7", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv3.checked).toBe(true); + expect(radio2lv3.checked).toBe(false); + + fireEvent.click(radio2lv4); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "8", lv5: { 5: "9" } } } } + } + }); + expect(radio1lv4.checked).toBe(false); + expect(radio2lv4.checked).toBe(true); + + fireEvent.click(radio2lv5); + expect(onChange).toHaveReturnedWith({ + lv1: { + 1: "1", + lv2: { 2: "4", lv3: { 3: "5", lv4: { 4: "8", lv5: { 5: "10" } } } } + } + }); + expect(radio1lv5.checked).toBe(false); + expect(radio2lv5.checked).toBe(true); + + fireEvent.click(reset); + expect(onReset).toHaveReturnedWith(initialState); + expect(radio1lv1.checked).toBe(false); + expect(radio2lv1.checked).toBe(true); + + expect(radio1lv2.checked).toBe(true); + expect(radio2lv2.checked).toBe(false); + + expect(radio1lv3.checked).toBe(false); + expect(radio2lv3.checked).toBe(true); + + expect(radio1lv4.checked).toBe(true); + expect(radio2lv4.checked).toBe(false); + + expect(radio1lv5.checked).toBe(true); + expect(radio2lv5.checked).toBe(false); + }); +}); diff --git a/__tests__/Form.spec.js b/__tests__/Form.spec.js index 463627d..632a352 100644 --- a/__tests__/Form.spec.js +++ b/__tests__/Form.spec.js @@ -1,9 +1,17 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { + render, + cleanup, + fireEvent, + waitForElement +} from "@testing-library/react"; import Form, { Input } from "./../src"; +import { SimpleFormTestSumbission } from "./helpers/components/SimpleFormTestSumbission"; +import { CollectionDynamicCart } from "./helpers/components/CollectionDynamicField"; import SimpleForm from "./helpers/components/SimpleForm"; +import SimpleFormWithAsync from "./helpers/components/SimpleFormWithAsync"; import { ComplexForm, ComplexFormInitValueAsProps, @@ -23,6 +31,8 @@ const onChange = jest.fn(); const onReset = jest.fn(); const onSubmit = jest.fn(); +afterEach(cleanup); + describe("Component => Form", () => { beforeEach(() => { onInit.mockClear(); @@ -77,7 +87,7 @@ describe("Component => Form", () => { const { getByTestId } = mountForm({ props, children }); const input = getByTestId(dataTestid); fireEvent.change(input, { target: { value: valueInput } }); - expect(onChange).toHaveBeenCalledWith({ [nameInput]: valueInput }); + expect(onChange).toHaveBeenCalledWith({ [nameInput]: valueInput }, true); }); it("should initialized the Form state", () => { @@ -100,16 +110,19 @@ describe("Component => Form", () => { const textField = getByTestId("name"); fireEvent.change(textField, { target: { value: "Antonio" } }); - expect(onChange).toHaveBeenCalledWith({ - user: { - name: "Antonio", - lastname: "anything", - email: "anything@google.com" - } - }); + expect(onChange).toHaveBeenCalledWith( + { + user: { + name: "Antonio", + lastname: "anything", + email: "anything@google.com" + } + }, + true + ); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith(initialState); + expect(onReset).toHaveBeenCalledWith(initialState, true); }); it("should render a Form with dynamic inputs and initial state passed to Form prop", () => { @@ -121,107 +134,143 @@ describe("Component => Form", () => { const addmore = getByTestId("addinput"); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1 + }, + true + ); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 2: 2 + }, + true + ); const select = getByTestId("select"); fireEvent.change(select, { target: { value: "3" } }); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - select: "3", - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + select: "3", + 1: 1, + 2: 2 + }, + true + ); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 2: 2 - }); + expect(onReset).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 2: 2 + }, + true + ); const sexM = getByTestId("sexm"); fireEvent.click(sexM); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1, + 2: 2 + }, + true + ); const removeinput = getByTestId("removeinput"); fireEvent.click(removeinput); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1 + }, + true + ); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1, + 3: 3 + }, + true + ); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 3: 3 - }); + expect(onReset).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 3: 3 + }, + true + ); const other1 = getByTestId("other1"); fireEvent.click(other1); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: [undefined, "3"], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: [undefined, "3"], + 1: 1, + 3: 3 + }, + true + ); fireEvent.click(other1); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: ["1", "3"], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: ["1", "3"], + 1: 1, + 3: 3 + }, + true + ); const sexF = getByTestId("sexf"); fireEvent.click(sexF); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "F", - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "F", + 1: 1, + 3: 3 + }, + true + ); const other2 = getByTestId("other2"); onChange.mockClear(); fireEvent.click(other2); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: ["1", undefined], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: ["1", undefined], + 1: 1, + 3: 3 + }, + true + ); }); it("should render a Form with dynamic inputs and initial state passed to each input as 'value' prop", () => { @@ -233,107 +282,143 @@ describe("Component => Form", () => { const addmore = getByTestId("addinput"); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1 + }, + true + ); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 2: 2 + }, + true + ); const select = getByTestId("select"); fireEvent.change(select, { target: { value: "3" } }); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - select: "3", - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + select: "3", + 1: 1, + 2: 2 + }, + true + ); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 2: 2 - }); + expect(onReset).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 2: 2 + }, + true + ); const sexM = getByTestId("sexm"); fireEvent.click(sexM); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1, - 2: 2 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1, + 2: 2 + }, + true + ); const removeinput = getByTestId("removeinput"); fireEvent.click(removeinput); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1 + }, + true + ); fireEvent.click(addmore); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "M", - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "M", + 1: 1, + 3: 3 + }, + true + ); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ - ...initialStateComplexForm, - 1: 1, - 3: 3 - }); + expect(onReset).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + 1: 1, + 3: 3 + }, + true + ); const other1 = getByTestId("other1"); fireEvent.click(other1); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: [undefined, "3"], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: [undefined, "3"], + 1: 1, + 3: 3 + }, + true + ); fireEvent.click(other1); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: ["1", "3"], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: ["1", "3"], + 1: 1, + 3: 3 + }, + true + ); const sexF = getByTestId("sexf"); fireEvent.click(sexF); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - sex: "F", - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + sex: "F", + 1: 1, + 3: 3 + }, + true + ); const other2 = getByTestId("other2"); onChange.mockClear(); fireEvent.click(other2); - expect(onChange).toHaveBeenCalledWith({ - ...initialStateComplexForm, - other: ["1", undefined], - 1: 1, - 3: 3 - }); + expect(onChange).toHaveBeenCalledWith( + { + ...initialStateComplexForm, + other: ["1", undefined], + 1: 1, + 3: 3 + }, + true + ); }); it("should reduce the Form state with the given reducer function", () => { @@ -371,6 +456,66 @@ describe("Component => Form", () => { ); }); + it("should run reducer functions applied to Form on fields removal", () => { + const reducers = jest.fn(value => { + const { cart = {} } = value; + const { list = {} } = cart; + const { items = [] } = list; + const result = items.reduce((acc, val) => { + acc += val; + return acc; + }, 0); + const newList = { ...list, result }; + const newCart = { ...cart, list: newList }; + + return { ...value, cart: newCart, cartResult: result }; + }); + const props = { onInit, onSubmit, onChange, onReset, reducers }; + + const children = [ + , + + ]; + + const { getByTestId } = mountForm({ children, props }); + expect(reducers).toHaveBeenCalled(); + expect(reducers).toHaveReturnedWith({ + cart: { list: { result: 0 } }, + cartResult: 0 + }); + + const addInput = getByTestId("addInput"); + const removeInput = getByTestId("removeInput"); + + fireEvent.click(addInput); + expect(reducers).toHaveBeenCalled(); + expect(reducers).toHaveReturnedWith({ + cart: { list: { items: [1], result: 1 } }, + cartResult: 1 + }); + + fireEvent.click(addInput); + expect(reducers).toHaveBeenCalled(); + expect(reducers).toHaveReturnedWith({ + cart: { list: { items: [1, 2], result: 3 } }, + cartResult: 3 + }); + + fireEvent.click(removeInput); + expect(reducers).toHaveBeenCalled(); + expect(reducers).toHaveReturnedWith({ + cart: { list: { items: [1], result: 1 } }, + cartResult: 1 + }); + + fireEvent.click(removeInput); + expect(reducers).toHaveBeenCalled(); + expect(reducers).toHaveReturnedWith({ + cart: { list: { result: 0 } }, + cartResult: 0 + }); + }); + it("should submit the Form", () => { const initialState = { user: { name: "foo", lastname: "anything", email: "anything@google.com" } @@ -396,4 +541,253 @@ describe("Component => Form", () => { fireEvent.click(submit); expect(onSubmit).not.toHaveBeenCalled(); }); + + it("should not `preventDefault` Form submission if action props is present and Form is Valid", () => { + const originalError = console.error; + console.error = jest.fn(); + const initialState = { + user: { name: "foo", lastname: "anything", email: "anything@google.com" } + }; + + const props = { + initialState, + action: "http://yourapiserver.com/submit" + }; + + const { getByTestId } = render(); + const form = getByTestId("form"); + + const isNotPrevented = fireEvent.submit(form); + + expect(isNotPrevented).toBe(true); + console.error = originalError; + }); + + it("should submit a valid Form with an action and not `preventDefault` form submission", () => { + const originalError = console.error; + console.error = jest.fn(); + const initialState = { + user: { name: "foo", lastname: "anything", email: "anything@google.com" } + }; + + const props = { + initialState, + onSubmit, + action: "http://yourapiserver.com/submit", + method: "POST" + }; + + const { getByTestId } = render(); + const form = getByTestId("form"); + + const inputName = getByTestId("name"); + const inputLastName = getByTestId("lastname"); + const inputEmail = getByTestId("email"); + + expect(inputName.value).toBe("foo"); + expect(inputLastName.value).toBe("anything"); + expect(inputEmail.value).toBe("anything@google.com"); + + expect(form.action).toBe(props.action); + expect(form.method).toMatch(/POST/i); + + const isNotPrevented = fireEvent.submit(form); + + expect(onSubmit).toHaveBeenCalled(); + expect(isNotPrevented).toBe(true); + + console.error = originalError; + }); + + it("should not submit a invalid Form with an action and `preventDefault` form submission", () => { + const originalError = console.error; + console.error = jest.fn(); + const initialState = { + user: { name: "foo", lastname: "anything", email: "anything_google.com" } + }; + + const props = { + initialState, + onSubmit, + action: "http://yourapiserver.com/submit", + method: "POST" + }; + + const { getByTestId } = render(); + const form = getByTestId("form"); + + expect(form.action).toBe(props.action); + expect(form.method).toMatch(/POST/i); + + const isNotPrevented = fireEvent.submit(form); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(isNotPrevented).toBe(false); + console.error = originalError; + }); + + it("should button being disabled for an a invalid Form with Async Fields validators functions", async () => { + const originalError = console.error; + console.error = jest.fn(); + const initialState = { + username: "foo" + }; + + const props = { + initialState, + onSubmit + }; + + const { getByTestId } = render(); + const form = getByTestId("form"); + const submitbutton = getByTestId("submit"); + + const asyncinput = getByTestId("asyncinput"); + expect(asyncinput.value).toBe("foo"); + + fireEvent.submit(form); + + const asyncStart = await waitForElement(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + const asyncError = await waitForElement(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(submitbutton.disabled).toBe(true); + console.error = originalError; + }); + + it("should count the total attempts and the total successfully submissions for sync onSubmit", () => { + let pressSubmit = 0; + const onSubmit = () => { + const myIndex = ++pressSubmit; + return myIndex % 2 === 0; + }; + const props = { onSubmit }; + const { getByTestId } = render(); + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + for (let i = 1; i <= 10; i++) { + fireEvent.click(submit); + } + let submitAttempts = getByTestId("submitAttempts"); + let submittedCounter = getByTestId("submittedCounter"); + + expect(submitAttempts.innerHTML).toBe("10"); + expect(submittedCounter.innerHTML).toBe("5"); + + fireEvent.click(reset); + expect(() => getByTestId("submitAttempts")).toThrow(); + expect(() => getByTestId("submittedCounter")).toThrow(); + }); + + it("should count the total attempts and the total successfully submissions for async onSubmit", async () => { + let pressSubmit = 0; + const onSubmit = () => { + const myIndex = ++pressSubmit; + return new Promise((res, rej) => { + myIndex % 2 === 0 ? res() : rej(); + }); + }; + const props = { onSubmit }; + const { getByTestId } = render(); + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + + for (let i = 1; i <= 10; i++) { + fireEvent.click(submit); + } + + const submitAttempts = await waitForElement(() => + getByTestId("submitAttempts") + ); + const submittedCounter = await waitForElement(() => + getByTestId("submittedCounter") + ); + + expect(submitAttempts.innerHTML).toBe("10"); + expect(submittedCounter.innerHTML).toBe("5"); + + fireEvent.click(reset); + expect(() => getByTestId("submitAttempts")).toThrow(); + expect(() => getByTestId("submittedCounter")).toThrow(); + }); + + it("should count the total attempts and the total successfully submissions for sync validation fields", () => { + const onSubmit = () => {}; + const props = { onSubmit, showEmail: true }; + const { getByTestId } = render(); + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + const email = getByTestId("email"); + + let CounterSubmitAttempts = getByTestId("CounterSubmitAttempts"); + let CounteSubmitted = getByTestId("CounteSubmitted"); + + for (let i = 1; i <= 5; i++) { + fireEvent.click(submit); + } + + expect(CounterSubmitAttempts.innerHTML).toBe("5"); + expect(CounteSubmitted.innerHTML).toBe("0"); + + fireEvent.click(reset); + expect(CounterSubmitAttempts.innerHTML).toBe("0"); + expect(CounteSubmitted.innerHTML).toBe("0"); + + fireEvent.change(email, { target: { value: "abc@sustancu.it" } }); + for (let i = 1; i <= 5; i++) { + fireEvent.click(submit); + } + expect(CounterSubmitAttempts.innerHTML).toBe("5"); + expect(CounteSubmitted.innerHTML).toBe("5"); + }); + + it("should count the total attempts and the total successfully submissions for async validation fields", async () => { + const onSubmit = () => true; + const targetSumbission = 1; + const targetAttempts = 1; + const props = { + onSubmit, + showCollection: true, + targetSumbission, + targetAttempts + }; + const { getByTestId } = render(); + const submit = getByTestId("submit"); + const reset = getByTestId("reset"); + const addInput = getByTestId("addInput"); + + let CounterSubmitAttempts = getByTestId("CounterSubmitAttempts"); + let CounteSubmitted = getByTestId("CounteSubmitted"); + + for (let i = 1; i <= 5; i++) { + fireEvent.click(submit); + } + + expect(CounterSubmitAttempts.innerHTML).toBe("5"); + expect(CounteSubmitted.innerHTML).toBe("0"); + + fireEvent.click(reset); + expect(CounterSubmitAttempts.innerHTML).toBe("0"); + expect(CounteSubmitted.innerHTML).toBe("0"); + + fireEvent.click(addInput); + fireEvent.click(addInput); + for (let i = 1; i <= 1; i++) { + fireEvent.click(submit); + } + + const submitAttempts = await waitForElement(() => + getByTestId("submitAttempts") + ); + expect(submitAttempts.innerHTML).toBe("1"); + + const submittedCounter = await waitForElement(() => + getByTestId("submittedCounter") + ); + expect(submittedCounter.innerHTML).toBe("1"); + }); }); diff --git a/__tests__/FormContext.spec.js b/__tests__/FormContext.spec.js index f4fd2cb..16cd94f 100644 --- a/__tests__/FormContext.spec.js +++ b/__tests__/FormContext.spec.js @@ -1,10 +1,17 @@ import React from "react"; -import { render, fireEvent, waitForElement } from "@testing-library/react"; +import { + render, + fireEvent, + waitForElement, + cleanup +} from "@testing-library/react"; import SimpleFormContext from "./helpers/components/SimpleFormContext"; const onChange = jest.fn(); const onSubmit = jest.fn(); +afterEach(cleanup); + describe("Component => FormContext", () => { beforeEach(() => { onChange.mockClear(); diff --git a/__tests__/Input.spec.js b/__tests__/Input.spec.js index 943bc63..3d451bf 100644 --- a/__tests__/Input.spec.js +++ b/__tests__/Input.spec.js @@ -1,19 +1,26 @@ import React from "react"; -import { render, fireEvent, waitForElement } from "@testing-library/react"; +import { + render, + fireEvent, + waitForElement, + cleanup +} from "@testing-library/react"; import Form, { Input } from "./../src"; import InputAsync from "./helpers/components/InputAsync"; import Submit from "./helpers/components/Submit"; import Reset from "./helpers/components/Reset"; +import { SimpleFormDynamicField } from "./helpers/components/SimpleForm"; const mountForm = ({ props = {}, children } = {}) => render(
{children}
); -const onInit = jest.fn(state => state); +const onInit = jest.fn(); const onChange = jest.fn(); const onSubmit = jest.fn(); const onReset = jest.fn(); +afterEach(cleanup); describe("Component => Input", () => { beforeEach(() => { @@ -32,13 +39,94 @@ describe("Component => Input", () => { expect(getByTestId(/email/i).type).toBe(type); }); + it("should render a Input of type checkbox", () => { + const type = "checkbox"; + const props = { onInit }; + + const children = [ + + ]; + const { getByTestId } = mountForm({ children, props }); + const checkbox = getByTestId(type); + expect(onInit).toHaveBeenCalledWith({ [type]: true }, true); + expect(checkbox.type).toBe(type); + expect(checkbox.checked).toBe(true); + }); + + it("should render a Input of type radio", () => { + const type = "radio"; + const props = { onInit }; + + const children = [ + + ]; + const { getByTestId } = mountForm({ children, props }); + const radio = getByTestId(type); + expect(onInit).toHaveBeenCalledWith({ [type]: "3" }, true); + expect(radio.type).toBe(type); + expect(radio.checked).toBe(true); + expect(radio.value).toBe("3"); + }); + it("should render Input of type range", () => { const type = "range"; + const props = { onChange }; const children = [ - + ]; - const { getByTestId } = mountForm({ children }); - expect(getByTestId(type).type).toBe(type); + const { getByTestId } = mountForm({ children, props }); + const range = getByTestId(type); + expect(range.type).toBe(type); + expect(range.min).toBe("0"); + expect(range.max).toBe("11"); + + fireEvent.change(range, { target: { value: "3" } }); + expect(onChange).toHaveBeenCalledWith({ range: 3 }, true); + expect(range.value).toBe("3"); + }); + + it("should render Input of type number", () => { + const type = "number"; + const props = { onChange }; + const children = [ + + ]; + const { getByTestId } = mountForm({ children, props }); + const number = getByTestId(type); + expect(number.type).toBe(type); + + fireEvent.change(number, { target: { value: "3" } }); + expect(onChange).toHaveBeenCalledWith({ [type]: 3 }, true); + expect(number.value).toBe("3"); + }); + + it("should render Input of type submit", () => { + const type = "submit"; + const props = { onSubmit }; + const children = [ + , + + ]; + const { getByTestId } = mountForm({ children, props }); + const submit = getByTestId(type); + expect(submit.type).toBe(type); + + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledWith({ text: "text" }, true); }); it("should trigger onChange event when the Input value changes", () => { @@ -58,19 +146,79 @@ describe("Component => Input", () => { fireEvent.change(input, { target: { value: "micky" } }); expect(onChangeInput).toHaveReturnedWith("micky"); + expect(input.value).toBe("micky"); }); it("should render a Input and changing its value", () => { - const type = "text"; const value = "test"; + const valueNumber = 1; + const props = { onChange }; const children = [ - + , + ]; const { getByTestId } = mountForm({ props, children }); + + const number = getByTestId(/number/i); + fireEvent.change(number, { target: { value: valueNumber } }); + expect(onChange).toHaveBeenCalledWith({ number: valueNumber }, true); + const input = getByTestId(/email/i); fireEvent.change(input, { target: { value } }); - expect(onChange).toHaveBeenCalledWith({ email: value }); + expect(onChange).toHaveBeenCalledWith( + { + email: value, + number: valueNumber + }, + true + ); + + fireEvent.change(number, { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith({ email: value }, true); + }); + + it("should reset a Radio inputs group to it's initial value after being changed", () => { + const type = "radio"; + const props = { onChange, onInit, onReset }; + const children = [ + , + , + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const radio1 = getByTestId("a"); + const radio2 = getByTestId("b"); + const radio3 = getByTestId("c"); + const reset = getByTestId("reset"); + + expect(onInit).toHaveBeenCalledWith({ sex: "M" }, true); + + fireEvent.click(radio1); + expect(onChange).toHaveBeenCalledWith({ sex: "F" }, true); + expect(radio1.checked).toBe(true); + expect(radio2.checked).toBe(false); + expect(radio3.checked).toBe(false); + + fireEvent.click(radio3); + expect(onChange).toHaveBeenCalledWith({ sex: "Other" }, true); + expect(radio1.checked).toBe(false); + expect(radio2.checked).toBe(false); + expect(radio3.checked).toBe(true); + + fireEvent.click(reset); + expect(onReset).toHaveBeenCalledWith({ sex: "M" }, true); + expect(radio1.checked).toBe(false); + expect(radio2.checked).toBe(true); + expect(radio3.checked).toBe(false); }); it("should use a reducer function to reduce the Input value", () => { @@ -89,7 +237,38 @@ describe("Component => Input", () => { /> ]; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: reducedValue }); + expect(onInit).toHaveBeenCalledWith({ [name]: reducedValue }, true); + }); + + it("should use sync validator functions to validate the Input", () => { + const value = "33"; + const name = "test"; + const props = { onReset, onInit, onChange }; + const children = [ + (val && val.length >= 3 ? undefined : "error")]} + />, + + ]; + + const { getByTestId } = mountForm({ children, props }); + expect(onInit).toHaveBeenCalledWith({ [name]: value }, false); + + const input = getByTestId("input"); + fireEvent.change(input, { target: { value: "1234" } }); + expect(onChange).toHaveBeenCalledWith({ [name]: "1234" }, true); + + fireEvent.change(input, { target: { value: "12" } }); + expect(onChange).toHaveBeenCalledWith({ [name]: "12" }, false); + + const reset = getByTestId("reset"); + fireEvent.click(reset); + expect(onReset).toHaveBeenCalledWith({ [name]: value }, false); }); it("should use an async validator function to validate the Input", async () => { @@ -118,15 +297,25 @@ describe("Component => Input", () => { expect(asyncError).toBeDefined(); expect(asyncError.textContent).toBe("Error"); - asyncinput.focus(); fireEvent.change(asyncinput, { target: { value: "1234" } }); - fireEvent.click(submit); + asyncinput.focus(); + asyncinput.blur(); + + expect(asyncinput.value).toBe("1234"); const asyncSuccess = await waitForElement(() => getByTestId("asyncSuccess") ); expect(asyncSuccess).toBeDefined(); expect(asyncSuccess.textContent).toBe("Success"); + + fireEvent.click(submit); + + const submittedCounter = await waitForElement(() => + getByTestId("submittedCounter") + ); + expect(submittedCounter.textContent).toBe("1"); + expect(onSubmit).toHaveBeenCalledWith({ [name]: "1234" }, true); fireEvent.click(reset); @@ -134,7 +323,7 @@ describe("Component => Input", () => { getByTestId("asyncNotStartedYet") ); expect(asyncNotStartedYet.textContent).toBe("asyncNotStartedYet"); - expect(onReset).toHaveBeenCalledWith({ [name]: value }); + expect(onReset).toHaveBeenCalledWith({ [name]: value }, false); }); it("should override the inital form state given a initial 'value' prop to the input", () => { @@ -145,38 +334,67 @@ describe("Component => Input", () => { let children = []; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: 1 }); + expect(onInit).toHaveBeenCalledWith({ [name]: 1 }, true); onInit.mockClear(); children = []; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: "foo" }); + expect(onInit).toHaveBeenCalledWith({ [name]: "foo" }, true); onInit.mockClear(); children = [ - + ]; - mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: "foo_radio" }); + const { getByTestId } = mountForm({ props, children }); + expect(onInit).toHaveBeenCalledWith({ [name]: "foo_radio" }, true); + const radio = getByTestId("radio"); + expect(radio.checked).toBe(true); onInit.mockClear(); children = [ - + ]; - mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: "foo_checkbox" }); + + const { getByTestId: getByTestIdForm2 } = mountForm({ props, children }); + expect(onInit).toHaveBeenCalledWith({ [name]: "foo_checkbox" }, true); + const checkbox = getByTestIdForm2("checkbox"); + expect(checkbox.checked).toBe(true); onInit.mockClear(); children = []; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: { a: 1 } }); + expect(onInit).toHaveBeenCalledWith({ [name]: { a: 1 } }, true); onInit.mockClear(); children = [ - + ]; - mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: 10 }); + const { getByTestId: getByTestIdForm3 } = mountForm({ props, children }); + expect(onInit).toHaveBeenCalledWith({ [name]: 10 }, true); + const range = getByTestIdForm3("range"); + expect(range.value).toBe("10"); }); it("should use a multiple reducers to reduce the Input value", () => { @@ -186,10 +404,96 @@ describe("Component => Input", () => { const reducedValue = 4; const name = "test"; const children = [ - + ]; - mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ [name]: reducedValue }); + const { getByTestId } = mountForm({ props, children }); + expect(onInit).toHaveBeenCalledWith({ [name]: reducedValue }, true); + const number = getByTestId("number"); + expect(number.value).toBe(`${reducedValue}`); + }); + + it("should reset the form state to initial fields value dynamically added", () => { + const props = { onInit, onChange, onReset }; + + const { getByTestId } = render(); + const buttonAdd = getByTestId("add"); + expect(onInit).toHaveBeenCalledWith({}, true); + + fireEvent.click(buttonAdd); + expect(onChange).toHaveBeenCalledWith( + { + radio: "2", + checkbox2: "2", + text2: "2" + }, + true + ); + + const radio = getByTestId("radio"); + fireEvent.click(radio); + expect(onChange).toHaveBeenCalledWith( + { + radio: "4", + checkbox2: "2", + text2: "2" + }, + true + ); + expect(radio.checked).toBe(true); + + const checkbox1 = getByTestId("checkbox1"); + const checkbox2 = getByTestId("checkbox2"); + + fireEvent.click(checkbox1); + expect(onChange).toHaveBeenCalledWith( + { + radio: "4", + checkbox1: "1", + checkbox2: "2", + text2: "2" + }, + true + ); + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + + const text1 = getByTestId("text1"); + fireEvent.change(text1, { target: { value: "micky" } }); + expect(onChange).toHaveBeenCalledWith( + { + radio: "4", + checkbox1: "1", + checkbox2: "2", + text2: "2", + text1: "micky" + }, + true + ); + expect(text1.value).toBe("micky"); + + const reset = getByTestId("reset"); + const text2 = getByTestId("text2"); + const radio2 = getByTestId("radio2"); + + fireEvent.click(reset); + expect(onReset).toHaveBeenCalledWith( + { + radio: "2", + checkbox2: "2", + text2: "2" + }, + true + ); + expect(radio2.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + expect(text2.value).toBe("2"); }); it("should throw an error for missing 'type'", () => { diff --git a/__tests__/Select.spec.js b/__tests__/Select.spec.js index a4cd5d6..15eac9a 100644 --- a/__tests__/Select.spec.js +++ b/__tests__/Select.spec.js @@ -1,5 +1,5 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, cleanup } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import Reset from "./helpers/components/Reset"; @@ -16,6 +16,8 @@ const onInit = jest.fn(state => state); const onChange = jest.fn(); const onReset = jest.fn(); +afterEach(cleanup); + describe("Component => Select", () => { beforeEach(() => { onInit.mockClear(); @@ -32,7 +34,9 @@ describe("Component => Select", () => { ]; const { getByTestId } = mountForm({ children, props }); - expect(getByTestId(dataTestid).name).toBe(name); + const select = getByTestId(dataTestid); + expect(select.name).toBe(name); + expect(select.value).toBe(value); expect(onInit).toHaveReturnedWith({ [name]: value }); }); @@ -52,8 +56,12 @@ describe("Component => Select", () => { ]; const { getByTestId } = mountForm({ children, props }); - expect(getByTestId(dataTestid).name).toBe(name); + const select = getByTestId(dataTestid); + expect(select.name).toBe(name); expect(onInit).toHaveReturnedWith({ [name]: ["1", "2"] }); + expect(select.selectedOptions.length).toBe(2); + expect(select.selectedOptions[0].value).toBe("1"); + expect(select.selectedOptions[1].value).toBe("2"); }); it("should render a Select and changing its value", () => { @@ -67,7 +75,8 @@ describe("Component => Select", () => { const { getByTestId } = mountForm({ props, children }); const select = getByTestId(dataTestid); fireEvent.change(select, { target: { value } }); - expect(onChange).toHaveBeenCalledWith({ [name]: value }); + expect(onChange).toHaveBeenCalledWith({ [name]: value }, true); + expect(select.value).toBe(value); }); it("should render a multiple Select and changing its value", () => { @@ -84,7 +93,9 @@ describe("Component => Select", () => { const select = getByTestId(dataTestid); userEvent.selectOptions(select, ["1", "3"]); - expect(onChange).toHaveBeenCalledWith({ [name]: ["1", "3"] }); + expect(onChange).toHaveBeenCalledWith({ [name]: ["1", "3"] }, true); + expect(select.selectedOptions[0].value).toBe("1"); + expect(select.selectedOptions[1].value).toBe("3"); }); it("should render a multiple Select with a inital value", () => { @@ -105,11 +116,14 @@ describe("Component => Select", () => { const select = getByTestId(dataTestid); userEvent.selectOptions(select, ["1", "2", "3", "4"]); - expect(onChange).toHaveBeenCalledWith({ [name]: ["1", "2", "3", "4"] }); + expect(onChange).toHaveBeenCalledWith( + { [name]: ["1", "2", "3", "4"] }, + true + ); const reset = getByTestId("reset"); fireEvent.click(reset); - expect(onReset).toHaveBeenCalledWith({ [name]: ["1", "2"] }); + expect(onReset).toHaveBeenCalledWith({ [name]: ["1", "2"] }, true); }); it("should use a reducer to reduce the Select value", () => { diff --git a/__tests__/TextArea.spec.js b/__tests__/TextArea.spec.js index 814b351..b62f140 100644 --- a/__tests__/TextArea.spec.js +++ b/__tests__/TextArea.spec.js @@ -1,5 +1,5 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, cleanup } from "@testing-library/react"; import Form, { TextArea } from "./../src"; @@ -9,6 +9,7 @@ const mountForm = ({ props = {}, children } = {}) => const dataTestid = "TextArea"; const name = "TextArea"; const value = "test"; +afterEach(cleanup); describe("Component => TextArea", () => { it("should render a TextArea", () => { @@ -28,6 +29,7 @@ describe("Component => TextArea", () => { const { getByTestId } = mountForm({ props, children }); const textArea = getByTestId(dataTestid); fireEvent.change(textArea, { target: { value } }); - expect(onChange).toHaveBeenCalledWith({ [name]: value }); + expect(onChange).toHaveBeenCalledWith({ [name]: value }, true); + expect(textArea.value).toBe(value); }); }); diff --git a/__tests__/helpers/components/CollectionAsyncValidation.jsx b/__tests__/helpers/components/CollectionAsyncValidation.jsx index d5d8bfb..4698b54 100644 --- a/__tests__/helpers/components/CollectionAsyncValidation.jsx +++ b/__tests__/helpers/components/CollectionAsyncValidation.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import { Input, Collection, @@ -12,16 +12,19 @@ const asyncTest = value => if (value.length <= 1) { reject("Add at least two Inputs"); } else resolve(); - }, 200); + }, 100); }); -let index = 0; export default function CollectionAsyncValidation() { const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); const [inputs, setInputs] = useChildren([]); + const index = useRef(0); const addInput = () => { - ++index; - setInputs(prev => [...prev, { index, value: index }]); + index.current = index.current + 1; + setInputs(prev => [ + ...prev, + { index: index.current, value: index.current } + ]); }; const removeInput = () => { @@ -32,10 +35,10 @@ export default function CollectionAsyncValidation() {

- -
@@ -47,6 +50,9 @@ export default function CollectionAsyncValidation() { {asyncStatus.status === "asyncStart" && ( )} + {asyncStatus.status === "asyncSuccess" && ( + + )} {asyncStatus.status === "asyncError" && ( )} diff --git a/__tests__/helpers/components/CollectionDynamicField.jsx b/__tests__/helpers/components/CollectionDynamicField.jsx new file mode 100644 index 0000000..1e46080 --- /dev/null +++ b/__tests__/helpers/components/CollectionDynamicField.jsx @@ -0,0 +1,406 @@ +import React, { + useRef, + useState, + useImperativeHandle, + forwardRef +} from "react"; +import { Collection, Input } from "../../../src"; + +export function CollectionDynamicField({ name = "dynamic", reducers }) { + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const addInput = () => { + index.current++; + setAdd(prev => [ + ...prev, + + ]); + }; + + const removeInput = () => + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + return prev.filter((elm, index) => index !== pos); + }); + + return ( +
+ + {" --- Start --- "} +
Start an array collection of inputs
+ {inputs} +
End an array collection of inputs
+ {" --- End --- "} +
+
+ + +
+ ); +} + +export function CollectionNestedDynamicField({ name = "dynamicNested" }) { + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const indexCollection = useRef(0); + const [collections, setCollection] = useState([]); + + const addInput = () => { + index.current++; + setAdd(prev => [ + ...prev, + + ]); + }; + + const removeInput = () => + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + return prev.filter((elm, index) => index !== pos); + }); + + const addCollection = () => { + indexCollection.current++; + setCollection(prev => [ + ...prev, + + {" --- Start --- "} + + {" --- End --- "} + + ]); + }; + + const removeCollection = () => { + setCollection(prev => { + const pos = Math.floor(Math.random() * prev.length); + return prev.filter((elm, index) => index !== pos); + }); + }; + + return ( +
+ + + {" --- Start --- "} +
Start an array collection of inputs
+ {inputs} +
End an array collection of inputs
+ {" --- End --- "} + {collections} +
+
+
+ + + + +
+ ); +} + +export function CollectionNestedRadioCheckbox({ + name = "dynamicRadioCheckbox" +}) { + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const indexCollection = useRef(0); + const [collections, setCollection] = useState([]); + + const addInput = () => { + index.current++; + setAdd(prev => [ + ...prev, + + ]); + }; + + const removeInput = () => + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + return prev.filter((elm, index) => index !== pos); + }); + + const addCollection = () => { + indexCollection.current++; + setCollection(prev => [ + ...prev, + + {" --- Start --- "} + + {" --- End --- "} + + ]); + }; + + const removeCollection = () => { + setCollection(prev => { + const items = [...prev]; + delete items[Math.floor(Math.random() * items.length)]; + return items.filter(elm => typeof elm !== "undefined"); + }); + }; + + return ( +
+ + + {" --- Start --- "} +
Start an array collection of inputs checkboxes
+ {inputs} +
End an array collection of inputs radios
+ {" --- End --- "} + {collections} +
+
+
+ + + + +
+ ); +} + +export const CollectionNestedRandomPosition = forwardRef((props, ref) => { + const { name = "dynamicRandomPosition" } = props; + const innerState = useRef([]); + + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + useImperativeHandle(ref, () => ({ + getInnerState() { + return innerState.current; + }, + setInnerState(state) { + innerState.current = state; + }, + setValue(index, value) { + innerState.current[index] = value; + } + })); + + const addInput = () => { + index.current++; + innerState.current.push(index.current); + + setAdd(prev => { + const newState = [ + ...prev, + + ]; + + return newState; + }); + }; + + const removeInput = () => { + const pos = Math.floor(Math.random() * inputs.length); + innerState.current.splice(pos, 1); + setAdd(prev => prev.filter((val, index) => index !== pos)); + }; + + return ( +
+ + {" --- Start --- "} +
Start an array collection of inputs
+ {inputs} +
End an array collection of inputs
+ {" --- End --- "} +
+
+ + +
+ ); +}); + +export const CollectionNestedRandomPositionCollection = forwardRef( + (props, ref) => { + const { name = "dynamicRandomPosition" } = props; + const innerState = useRef([]); + + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + useImperativeHandle(ref, () => ({ + getInnerState() { + return innerState.current; + }, + setInnerState(state) { + innerState.current = state; + }, + setValue(index, value) { + innerState.current[index] = value; + } + })); + + const addCollection = () => { + index.current++; + innerState.current.push([index.current]); + setAdd(prev => { + const newState = [ + ...prev, + +
some label
+ +
+ ]; + + return newState; + }); + }; + + const removeCollection = () => { + const pos = Math.floor(Math.random() * inputs.length); + innerState.current.splice(pos, 1); + setAdd(prev => prev.filter((val, index) => index !== pos)); + }; + + return ( +
+ + {" --- Start --- "} +
Start an array collection of inputs
+ {inputs} +
End an array collection of inputs
+ {" --- End --- "} +
+
+ + +
+ ); + } +); + +export function CollectionDynamicCart({ reducers }) { + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const addInput = () => { + index.current++; + setAdd(prev => [ + ...prev, + + ]); + }; + + const removeInput = () => + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + return prev.filter((elm, index) => index !== pos); + }); + + return ( +
+ + + + {" --- Start --- "} +
Start an array collection of inputs
+ {inputs} +
End an array collection of inputs
+ {" --- End --- "} +
+ +
+
+
+ + +
+ ); +} diff --git a/__tests__/helpers/components/CollectionObjectNested.jsx b/__tests__/helpers/components/CollectionObjectNested.jsx index 9c46153..aaf1aef 100644 --- a/__tests__/helpers/components/CollectionObjectNested.jsx +++ b/__tests__/helpers/components/CollectionObjectNested.jsx @@ -164,3 +164,61 @@ export const reducerObjectNested = (value, prevValue) => { return newValue; }; + +export function CollectionObjectNestedRadios({ + initialValue, + checked = false +}) { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/__tests__/helpers/components/CollectionWithHooks.jsx b/__tests__/helpers/components/CollectionWithHooks.jsx index 6778e96..6253269 100644 --- a/__tests__/helpers/components/CollectionWithHooks.jsx +++ b/__tests__/helpers/components/CollectionWithHooks.jsx @@ -1,22 +1,28 @@ import React from "react"; -import { useCollection } from "./../../../src"; +import { useCollection, withIndex } from "./../../../src"; -export default function CollectionWithHooks({ value, type = "object" }) { +export function CollectionWithHooks({ + value, + type = "object", + name, + dataTestid = "changeCollection", + propToChange +}) { const { updateCollection } = useCollection({ - name: "hook", + name, type, value }); - const onUpdateCollection = () => updateCollection("name", "foo"); + const propName = propToChange || name; + + const onUpdateCollection = () => updateCollection(propName, "foo"); return ( - ); } + +export default withIndex(CollectionWithHooks); diff --git a/__tests__/helpers/components/Email.jsx b/__tests__/helpers/components/Email.jsx index d1b3dbd..cda5638 100644 --- a/__tests__/helpers/components/Email.jsx +++ b/__tests__/helpers/components/Email.jsx @@ -7,7 +7,7 @@ const email = value => : "Mail not Valid"; const required = value => (value && value !== "" ? undefined : "Required"); -export default function Email({ name, value }) { +export default function Email({ name = "email", value }) { const [status, validation] = useValidation([required, email]); return (
diff --git a/__tests__/helpers/components/InputAsync.jsx b/__tests__/helpers/components/InputAsync.jsx index 7485191..23099e4 100644 --- a/__tests__/helpers/components/InputAsync.jsx +++ b/__tests__/helpers/components/InputAsync.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Input, useAsyncValidation } from "./../../../src"; +import { Input, useAsyncValidation, withIndex } from "./../../../src"; const asyncTest = value => new Promise((resolve, reject) => { @@ -10,7 +10,16 @@ const asyncTest = value => }, 50); }); -export default function InputAsync({ name, dataTestid = "asyncinput", value }) { +export function InputAsync({ + name, + dataTestidNotStart = "asyncNotStartedYet", + dataTestid = "asyncinput", + dataTestidStart = "asyncStart", + dataTestidError = "asyncError", + dataTestidSuccess = "asyncSuccess", + value, + index +}) { const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); return ( @@ -18,6 +27,7 @@ export default function InputAsync({ name, dataTestid = "asyncinput", value }) { {asyncStatus.status === undefined && ( - + )} {asyncStatus.status === "asyncStart" && ( - + )} {asyncStatus.status === "asyncError" && ( - + )} {asyncStatus.status === "asyncSuccess" && ( - + )}
); } + +export default withIndex(InputAsync); diff --git a/__tests__/helpers/components/Reset.jsx b/__tests__/helpers/components/Reset.jsx index 80d18e6..afc1293 100644 --- a/__tests__/helpers/components/Reset.jsx +++ b/__tests__/helpers/components/Reset.jsx @@ -1,12 +1,16 @@ import React from "react"; -import { useForm } from "./../../../src"; +import { useForm, STATUS } from "./../../../src"; const Reset = () => { - const { reset, pristine } = useForm(); + const { reset, pristine, formStatus } = useForm(); return ( +
+ ); +}; diff --git a/__tests__/helpers/components/SimpleFormTestSumbission.jsx b/__tests__/helpers/components/SimpleFormTestSumbission.jsx new file mode 100644 index 0000000..bfd9cd7 --- /dev/null +++ b/__tests__/helpers/components/SimpleFormTestSumbission.jsx @@ -0,0 +1,63 @@ +import React from "react"; +import Email from "./Email"; +import CollectionAsyncValidation from "./CollectionAsyncValidation"; +import Form, { useForm } from "./../../../src"; + +export const SimpleFormTestSumbission = ({ + targetSumbission, + targetAttempts, + showEmail, + showCollection, + ...props +}) => { + return ( +
+
+ {showCollection && } + {showEmail && } + + + + +
+ ); +}; + +function Counter() { + const { submitted, submitAttempts } = useForm(); + return ( + <> + {submitAttempts} + {submitted} + + ); +} + +function Submit({ targetSumbission = 5, targetAttempts = 10 }) { + const { submitted, submitAttempts } = useForm(); + return ( + <> + + {targetAttempts === submitAttempts && ( + {submitAttempts} + )} + {targetSumbission === submitted && ( + {submitted} + )} + + ); +} + +function Reset() { + const { reset } = useForm(); + return ( + + ); +} diff --git a/__tests__/helpers/components/SimpleFormWithAsync.jsx b/__tests__/helpers/components/SimpleFormWithAsync.jsx new file mode 100644 index 0000000..468ef0a --- /dev/null +++ b/__tests__/helpers/components/SimpleFormWithAsync.jsx @@ -0,0 +1,64 @@ +import React from "react"; +import Form, { Collection } from "./../../../src"; +import Submit from "./Submit"; +import Reset from "./Reset"; +import InputAsync from "./InputAsync"; +import CollectionAsyncValidation from "./CollectionAsyncValidation"; +import Email from "./Email"; + +const SimpleFormWithAsync = props => ( +
+ + + + +); + +export default SimpleFormWithAsync; + +export const expectedInitialState = { + address: { city: "Milan", details: ["333"] }, + username: "Antonio" +}; + +const initialState = { + username: "Antonio", + address: { details: ["333"] } +}; + +export const SimpleFormWithAsyncStrictMode = props => ( +
+ + + + + + + + + + + + +); diff --git a/__tests__/helpers/components/Submit.jsx b/__tests__/helpers/components/Submit.jsx index a8c1529..667109c 100644 --- a/__tests__/helpers/components/Submit.jsx +++ b/__tests__/helpers/components/Submit.jsx @@ -1,11 +1,18 @@ import React from "react"; import { useForm } from "./../../../src"; -export default function Sumbit() { - const { isValid } = useForm(); +export default function Sumbit({ forceEnable }) { + const { isValid, submitted } = useForm(); return ( - + <> + + {submitted} + ); } diff --git a/__tests__/helpers/components/User.jsx b/__tests__/helpers/components/User.jsx index 7bb1c47..0072148 100644 --- a/__tests__/helpers/components/User.jsx +++ b/__tests__/helpers/components/User.jsx @@ -6,8 +6,8 @@ export default function User({ name }) {
- - + +
); diff --git a/__tests__/hooks/useCollection.spec.js b/__tests__/hooks/useCollection.spec.js index 3539bdd..bf86d55 100644 --- a/__tests__/hooks/useCollection.spec.js +++ b/__tests__/hooks/useCollection.spec.js @@ -1,14 +1,15 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import Form from "./../../src"; +import { render, fireEvent, cleanup } from "@testing-library/react"; +import Form, { Collection } from "./../../src"; import CollectionWithHooks from "./../helpers/components/CollectionWithHooks"; const mountForm = ({ props = {}, children } = {}) => render(
{children}
); -const onInit = jest.fn(state => state); +const onInit = jest.fn(); const onChange = jest.fn(); +afterEach(cleanup); describe("Hooks => useCollection", () => { beforeEach(() => { @@ -17,26 +18,82 @@ describe("Hooks => useCollection", () => { }); it("should change Collection value due to an action", () => { const props = { onChange }; - const children = []; + const children = [ + + ]; const { getByTestId } = mountForm({ props, children }); const changeCollection = getByTestId("changeCollection"); fireEvent.click(changeCollection); - expect(onChange).toHaveBeenCalledWith({ hook: { name: "foo" } }); + expect(onChange).toHaveBeenCalledWith({ test: { lastname: "foo" } }, true); }); it("should create a Collection with an initial value", () => { const props = { onInit }; - const children = []; + const children = [ + + ]; mountForm({ props, children }); - expect(onInit).toHaveReturnedWith({ hook: { name: "test" } }); + expect(onInit).toHaveBeenCalledWith({ test: { name: "foo" } }, true); + }); + + it("should create a nested Collection with an initial value", () => { + const props = { onInit }; + const children = [ + + + + + ]; + mountForm({ props, children }); + expect(onInit).toHaveBeenCalledWith( + { + array: [{ name: "foo" }, { lastname: "foo" }] + }, + true + ); + }); + + it("should change a nested Collection value due to an action", () => { + const props = { onChange }; + const children = [ + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const changeCollectionHook1 = getByTestId("hook1"); + fireEvent.click(changeCollectionHook1); + expect(onChange).toHaveBeenCalledWith({ array: [{ 0: "foo" }] }, true); + + onChange.mockClear(); + const changeCollectionHook2 = getByTestId("hook2"); + fireEvent.click(changeCollectionHook2); + expect(onChange).toHaveBeenCalledWith( + { + array: [{ 0: "foo" }, { 1: "foo" }] + }, + true + ); }); it("should throw an error for invalids initial values", () => { const originalError = console.error; console.error = jest.fn(); const children = [ - + ]; expect(() => mountForm({ children })).toThrowError( /it is not allowed as initial value/i diff --git a/__tests__/hooks/useField.spec.js b/__tests__/hooks/useField.spec.js new file mode 100644 index 0000000..9605088 --- /dev/null +++ b/__tests__/hooks/useField.spec.js @@ -0,0 +1,336 @@ +import React from "react"; +import { render, fireEvent, cleanup } from "@testing-library/react"; +import Form, { Collection, useField, withIndex } from "./../../src"; + +const InputCustom = withIndex(({ type, name, value, index, ...restAttr }) => { + const props = useField({ type, name, value, index }); + return ; +}); + +const InputCustomNoAutoIndex = ({ type, name, value, index, ...restAttr }) => { + const props = useField({ type, name, value, index }); + return ; +}; + +const mountForm = ({ props = {}, children } = {}) => + render(
{children}
); + +const onInit = jest.fn(); +const onChange = jest.fn(); +afterEach(cleanup); + +describe("Hooks => useField", () => { + beforeEach(() => { + onInit.mockClear(); + onChange.mockClear(); + }); + + it("should change a Field value due to an action", () => { + const props = { onChange }; + const children = [ + , + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + onChange.mockClear(); + fireEvent.change(input1, { target: { value: "50" } }); + expect(input1.value).toBe("50"); + expect(onChange).toHaveBeenCalledWith({ number: "50", number2: "4" }, true); + + onChange.mockClear(); + fireEvent.change(input2, { target: { value: "5" } }); + expect(input2.value).toBe("5"); + expect(onChange).toHaveBeenCalledWith({ number: "50", number2: "5" }, true); + }); + + it("should change a Field value due to an action", () => { + const props = { onChange }; + const children = [ + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + onChange.mockClear(); + fireEvent.change(input1, { target: { value: "50" } }); + expect(input1.value).toBe("50"); + expect(onChange).toHaveBeenCalledWith({ array: ["50"] }, true); + + onChange.mockClear(); + fireEvent.change(input2, { target: { value: "60" } }); + expect(input2.value).toBe("60"); + expect(onChange).toHaveBeenCalledWith({ array: ["50", "60"] }, true); + }); + + it("should nest Fields into array Collection", () => { + const props = { onChange }; + const children = [ + + + + + + + + + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + const input3 = getByTestId("input3"); + const input4 = getByTestId("input4"); + + onChange.mockClear(); + fireEvent.change(input1, { target: { value: "50" } }); + expect(input1.value).toBe("50"); + expect(onChange).toHaveBeenCalledWith({ array: [["50"]] }, true); + + onChange.mockClear(); + fireEvent.change(input2, { target: { value: "60" } }); + expect(onChange).toHaveBeenCalledWith({ array: [["50", "60"]] }, true); + expect(input2.value).toBe("60"); + + onChange.mockClear(); + fireEvent.change(input3, { target: { value: "hello" } }); + expect(onChange).toHaveBeenCalledWith( + { + array: [["50", "60"], [["hello"]]] + }, + true + ); + expect(input3.value).toBe("hello"); + + onChange.mockClear(); + fireEvent.change(input4, { target: { value: "world" } }); + expect(onChange).toHaveBeenCalledWith( + { + array: [["50", "60"], [["hello", "world"]]] + }, + true + ); + expect(input4.value).toBe("world"); + }); + + it("should nest Checkboxes into array Collection", () => { + const props = { onChange }; + const children = [ + + + + + + + + + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + const input3 = getByTestId("input3"); + const input4 = getByTestId("input4"); + + onChange.mockClear(); + fireEvent.click(input1); + expect(input1.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith({ array: [["blue"]] }, true); + + onChange.mockClear(); + fireEvent.click(input2); + expect(input2.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith({ array: [["blue", "green"]] }, true); + + onChange.mockClear(); + fireEvent.click(input3); + expect(input3.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith( + { + array: [["blue", "green"], [["yellow"]]] + }, + true + ); + + onChange.mockClear(); + fireEvent.click(input4); + expect(input4.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith( + { + array: [["blue", "green"], [["yellow", true]]] + }, + true + ); + }); + + it("should nest Radios into array Collection", () => { + const props = { onChange }; + const children = [ + + + + + + + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + onChange.mockClear(); + fireEvent.click(input1); + expect(input1.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith({ array: [["blue"]] }, true); + + onChange.mockClear(); + fireEvent.click(input2); + expect(input2.checked).toBe(true); + expect(onChange).toHaveBeenCalledWith( + { + array: [["blue"], [["yellow"]]] + }, + true + ); + }); + + it("should nest Fields into object Collection", () => { + const props = { onChange, onInit }; + const initialValue = { array: [[["100", "200"]]] }; + const children = [ + + + + + + + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + expect(onInit).toHaveBeenCalledWith({ object: initialValue }, true); + + onChange.mockClear(); + fireEvent.change(input1, { target: { value: "50" } }); + expect(input1.value).toBe("50"); + expect(onChange).toHaveBeenCalledWith( + { + object: { array: [[["50", "200"]]] } + }, + true + ); + + onChange.mockClear(); + fireEvent.change(input2, { target: { value: "60" } }); + expect(input2.value).toBe("60"); + expect(onChange).toHaveBeenCalledWith( + { + object: { array: [[["50", "60"]]] } + }, + true + ); + }); + + it("should nest Fields into array Collection with a given index", () => { + const props = { onChange }; + const children = [ + + + + + + + + + + + + + ]; + const { getByTestId } = mountForm({ props, children }); + + const input1 = getByTestId("input1"); + const input2 = getByTestId("input2"); + + const input3 = getByTestId("input3"); + const input4 = getByTestId("input4"); + + onChange.mockClear(); + fireEvent.change(input1, { target: { value: "50" } }); + expect(input1.value).toBe("50"); + expect(onChange).toHaveBeenCalledWith({ array: [["50"]] }, true); + + onChange.mockClear(); + fireEvent.change(input2, { target: { value: "60" } }); + expect(onChange).toHaveBeenCalledWith({ array: [["50", "60"]] }, true); + expect(input2.value).toBe("60"); + + onChange.mockClear(); + fireEvent.change(input3, { target: { value: "hello" } }); + expect(onChange).toHaveBeenCalledWith( + { + array: [["50", "60"], [["hello"]]] + }, + true + ); + expect(input3.value).toBe("hello"); + + onChange.mockClear(); + fireEvent.change(input4, { target: { value: "world" } }); + expect(onChange).toHaveBeenCalledWith( + { + array: [["50", "60"], [["hello", "world"]]] + }, + true + ); + expect(input4.value).toBe("world"); + }); +}); diff --git a/__tests__/hooks/useMultipleForm.spec.js b/__tests__/hooks/useMultipleForm.spec.js index 348ac96..b64f712 100644 --- a/__tests__/hooks/useMultipleForm.spec.js +++ b/__tests__/hooks/useMultipleForm.spec.js @@ -1,8 +1,9 @@ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; - +import { render, fireEvent, cleanup } from "@testing-library/react"; import Wizard from "./../helpers/components/Wizard"; +afterEach(cleanup); + describe("Hooks => useMultipleForm", () => { it("should handle multiple Forms as a Wizard", () => { const onChangeWizard = jest.fn(); diff --git a/build_config/build.js b/build_config/build.js index 104f9fe..a62c395 100755 --- a/build_config/build.js +++ b/build_config/build.js @@ -1,42 +1,42 @@ -const execSync = require("child_process").execSync; - -const exec = (command, extraEnv) => - execSync(command, { - stdio: "inherit", - env: Object.assign({}, process.env, extraEnv) - }); - -console.log("Building CommonJS modules ..."); - -exec("rollup -c -f cjs -o build/index.js", { - BABEL_ENV: "cjs", - NODE_ENV: "production" -}); - -console.log("\nBuilding ES modules through Babel ..."); - -exec("babel src -d build/es --ignore __tests__", { - BABEL_ENV: "es-babel", - NODE_ENV: "production" -}); - -console.log("\nBuilding ES modules through Rollup ..."); - -exec("rollup -c -f es -o build/index.es.js", { - BABEL_ENV: "es-rollup", - NODE_ENV: "production" -}); - -console.log("\nBuilding UMD index.js ..."); - -exec("rollup -c -f umd -o build/umd/index.js", { - BABEL_ENV: "umd", - NODE_ENV: "development" -}); - -console.log("\nBuilding UMD index.min.js ..."); - -exec("rollup -c -f umd -o build/umd/index.min.js", { - BABEL_ENV: "umd", - NODE_ENV: "production" -}); +const execSync = require("child_process").execSync; + +const exec = (command, extraEnv) => + execSync(command, { + stdio: "inherit", + env: Object.assign({}, process.env, extraEnv) + }); + +console.log("Building CommonJS modules ..."); + +exec("rollup -c -f cjs -o build/index.js", { + BABEL_ENV: "cjs", + NODE_ENV: "production" +}); + +console.log("\nBuilding ES modules through Babel ..."); + +exec("babel src -d build/es --ignore __tests__", { + BABEL_ENV: "es-babel", + NODE_ENV: "production" +}); + +console.log("\nBuilding ES modules through Rollup ..."); + +exec("rollup -c -f es -o build/index.es.mjs", { + BABEL_ENV: "es-rollup", + NODE_ENV: "production" +}); + +console.log("\nBuilding UMD index.js ..."); + +exec("rollup -c -f umd -o build/umd/index.js", { + BABEL_ENV: "umd", + NODE_ENV: "development" +}); + +console.log("\nBuilding UMD index.min.js ..."); + +exec("rollup -c -f umd -o build/umd/index.min.js", { + BABEL_ENV: "umd", + NODE_ENV: "production" +}); diff --git a/build_config/dev.js b/build_config/dev.js new file mode 100644 index 0000000..30333ff --- /dev/null +++ b/build_config/dev.js @@ -0,0 +1,14 @@ +const execSync = require("child_process").execSync; + +const exec = (command, extraEnv) => + execSync(command, { + stdio: "inherit", + env: Object.assign({}, process.env, extraEnv) + }); + +console.log("\nBuilding UMD index.js ..."); + +exec("rollup -c -f umd -m inline -w src -o dev/index.js", { + BABEL_ENV: "umd", + NODE_ENV: "development" +}); diff --git a/docs/Collection.mdx b/docs/Collection.mdx index 27937ff..71ab79f 100644 --- a/docs/Collection.mdx +++ b/docs/Collection.mdx @@ -5,34 +5,42 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; -import { Collection, useValidation, Input } from './../src'; +import { Submit } from "./helpers/Submit"; +import { CustomInput } from "./helpers/CustomInput" +import { Collection, useValidation, Input, useAsyncValidation } from './../src'; # Collection It creates a nested piece of state within a Form.
-A Collection can be of type: *object* or *array*. +A Collection can be of type: **object** or **array**. ### Props -**`object`**: `boolean` +**`object`**: boolean It creates a collecion of type **object** if "true". -**`array`**: `boolean` +**`array`**: boolean It creates a collecion of type **array** if "true". -**`name`**: `string` +**`name`**: string - (except for **Collection** children of Collection of type array) A field's name in Usetheform state. -**`value`**: `array` | `object` +**`index`**: string - (only for **Collection** children of Collection of type array) + +A field's index in array Collection. + +**`value`**: array | object Specifies the initial value of a *Collection*. -**`reducers`**: `array` | `function` +**`reducers`**: array | function -*`(nextValue, prevValue, formState) => nextValue`* +```javascript +(nextValue, prevValue, formState) => nextValue +``` An array whose values correspond to different reducing functions. Reducers functions specify how the Collection's value change. @@ -46,18 +54,19 @@ Reducers functions specify how the Collection's value change.
- + + - - - + + - - - + + + +
@@ -78,14 +87,76 @@ Reducers functions specify how the Collection's value change. - - - + + + +## Array Collection + +Array Collection of Input fields with indexes handled automatically. + +```javascript +import React from "react"; +import Form { Input, Collection } from "usetheform"; +``` + + +
+ + + + +
+
+ +
+Array Collection of Input fields with indexes handled automatically for custom Inputs. + +```javascript +import React from "react"; +import { withIndex, useField, Collection } from "usetheform"; + +const CustomInput = withIndex(({ type, name, value, index, ...restAttr }) => { + const props = useField({ type, name, value, index }); + return ; +}); +``` + + +
+ + + + +
+
+ +
+Array Collection of Input fields with indexes handled maunally. + +```javascript + import Form, { Input, Collection } from 'usetheform' +``` + + +
+ + + + +
+
+ ## Reducers ```javascript @@ -94,17 +165,19 @@ Reducers functions specify how the Collection's value change. {() => { - const sum = nextValue => { - const sumAB = nextValue["A"] + nextValue["B"]; - const newValue = { ...nextValue, sumAB }; + const fullNameFN = nextValue => { + const fullName = [nextValue["name"], nextValue["lastname"]] + .filter(Boolean) + .join(" "); + const newValue = { ...nextValue, fullName }; return newValue; }; return (
- - - - + + + + ) @@ -112,7 +185,7 @@ Reducers functions specify how the Collection's value change. }
-## Validation +## Validation - Sync Validation for Collection starts only on form submission. @@ -136,4 +209,53 @@ Validation for Collection starts only on form submission. ) } } - \ No newline at end of file + + +## Validation - Async + +Async Validation for Collections are triggered on Sumbit event, the form submission is prevented if the validation fails. + +```javascript +import { useAsyncValidation, useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +``` + + +{() => { + const asyncTest = value => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (value.a + value.b !== 5) { + reject("Error values not allowed"); + } else { + resolve("Success"); + } + }, 1000); + }); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); + return ( +
+ + + + + {asyncStatus.status === undefined && } + {asyncStatus.status === "asyncStart" && } + {asyncStatus.status === "asyncError" && } + {asyncStatus.status === "asyncSuccess" && } + + + ) + } +} +
diff --git a/docs/Form.mdx b/docs/Form.mdx index ba07d1d..edf0d6f 100644 --- a/docs/Form.mdx +++ b/docs/Form.mdx @@ -12,34 +12,75 @@ The Form is the most important component in Usetheform. It renders all the Field ### Props -**`onInit`**: `function` +**`onInit`**: function A function invoked when the Form is initialized. -**`onChange`**: `function` +```javascript +const onInit = (formState, isFormValid) => { // some operation } +``` + +**`onChange`**: function -A function invoked when any Form Field change its value. +A function invoked when any Form Field changes its value. + +```javascript +const onChange = (formState, isFormValid) => { // some operation } +``` -**`onReset`**: `function` +**`onReset`**: function A function invoked when the form has been reset to its initial State. -**`onSubmit`**: `function` +```javascript +const onReset = (formState, isFormValid) => { // some operation } +``` + +**`onSubmit`**: function + +A function invoked when the submit button has been pressed. +The function may return either a Promise or a boolean value true/false. + +```js +const onSubmit = (formState) => { // some operation }; +const onSubmit = (formState) => new Promise((resolve, reject) => { // some async operation }); +``` + +Cases: + - If the function returns a Promise which is resolved it will increment the value named **submitted**. + - If the function returns a boolean value `true` or no return at all it will increment the value named **submitted**. + - If the function returns a Promise which is rejected the value named **submitted** will not be incremented. + - If the function returns a boolean value `false` the value named **submitted** will not be incremented. + +```js + const { submitted, submitAttempts } = useForm(); +``` -A function invoked when the form has been submitted. It will be only invoked if your form passes all the validations added at any level (Collections or Fields). +For each invokation the value **submitAttempts** will be incremented. -**`initialState`**: `object` +**`initialState`**: object It is a plain object that rappresent the initial state of the form. -**`reducers`**: `array` | `function` +**`reducers`**: array | function + +```javascript +(nextState, prevState) => nextState +``` -*`(nextState, prevState) => nextState`* - An array whose values correspond to different reducing functions. Reducers functions specify how the Form's state change. +**`action`**: string + +The action attribute specifies where to send the form-data when a form is submitted. + +Possible values: + + - An absolute URL - points to another web site (like action="http://www.example.com/example.htm") + - A relative URL - points to a file within a web site (like action="example.htm") + ## Basic usage ```javascript @@ -48,9 +89,9 @@ Reducers functions specify how the Form's state change.
console.log("INIT", state)} - onChange={state => console.log("CHANGE", state)} - onSubmit={state => console.log("SUBMIT", state)} + onInit={(state, isFormValid) => console.log("INIT", state, isFormValid)} + onChange={(state, isFormValid) => console.log("CHANGE", state, isFormValid)} + onSubmit={(state, isFormValid) => console.log("SUBMIT", state, isFormValid)} > diff --git a/docs/FormContext.mdx b/docs/FormContext.mdx index a61eb8f..dd42ee0 100644 --- a/docs/FormContext.mdx +++ b/docs/FormContext.mdx @@ -13,31 +13,63 @@ It is a react component that provides a context of the "form" at wider level. ### Props -**`onInit`**: `function` +**`onInit`**: function A function invoked when the Form is initialized. -**`onChange`**: `function` +```javascript +const onInit = (formState, isFormValid) => { // some operation } +``` + +**`onChange`**: function -A function invoked when any Form Field change its value. +A function invoked when any Form Field changes its value. + +```javascript +const onChange = (formState, isFormValid) => { // some operation } +``` -**`onReset`**: `function` +**`onReset`**: function A function invoked when the form has been reset to its initial State. -**`onSubmit`**: `function` +```javascript +const onReset = (formState, isFormValid) => { // some operation } +``` + +**`onSubmit`**: function + +A function invoked when the submit button has been pressed. +The function may return either a Promise or a boolean value true/false. + +```js +const onSubmit = (formState) => { // some operation }; +const onSubmit = (formState) => new Promise((resolve, reject) => { // some async operation }); +``` + +Cases: + - If the function returns a Promise which is resolved it will increment the value named **submitted**. + - If the function returns a boolean value `true` or no return at all it will increment the value named **submitted**. + - If the function returns a Promise which is rejected the value named **submitted** will not be incremented. + - If the function returns a boolean value `false` the value named **submitted** will not be incremented. + +```js + const { submitted, submitAttempts } = useForm(); +``` -A function invoked when the form has been submitted. It will be only invoked if your form passes all the validations added at any level (Collections or Fields). +For each invokation the value **submitAttempts** will be incremented. -**`initialState`**: `object` +**`initialState`**: object It is a plain object that rappresent the initial state of the form. -**`reducers`**: `array` | `function` +**`reducers`**: array | function + +```javascript +(nextState, prevState) => nextState +``` -*`(nextState, prevState) => nextState`* - An array whose values correspond to different reducing functions. Reducers functions specify how the Form's state change. @@ -74,9 +106,9 @@ export const Form = ({ children }) => { return ( console.log("INIT", state)} - onChange={state => console.log("CHANGE", state)} - onSubmit={state => console.log("SUBMIT", state)} + onInit={(state, isFormValid) => console.log("INIT", state, isFormValid)} + onChange={(state, isFormValid) => console.log("CHANGE", state, isFormValid)} + onSubmit={(state, isFormValid) => console.log("SUBMIT", state, isFormValid)} > diff --git a/docs/Input.mdx b/docs/Input.mdx index 3052637..86abed3 100644 --- a/docs/Input.mdx +++ b/docs/Input.mdx @@ -5,33 +5,44 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; -import { Input, useValidation } from './../src'; +import { Submit } from "./helpers/Submit"; +import { Input, useValidation, useAsyncValidation } from './../src'; # Input It renders all the inputs of type listed at: [W3schools Input Types](https://www.w3schools.com/html/html_form_input_types.asp) and accepts as props any html attribute listed at: [Html Input Attributes](https://www.w3schools.com/tags/tag_input.asp). ### Props -**`type`**: `string` +**`type`**: string Type listed at: [W3schools Input Types](https://www.w3schools.com/html/html_form_input_types.asp). It also supports a `custom` type (type="custom"). -**`name`**: `string` +**`name`**: string - (except for **Input** children of Collection of type array) A field's name in Usetheform state. -**`value`**: `string` | `number` | `object` +**`index`**: string - (only for **Input** children of Collection of type array) + +A field's index in array Collection. + +**`value`**: string | number | object Specifies the initial value of an *input* element. -**`checked`**: `boolean` +**`checked`**: boolean Specifies that an *input* element should be pre-selected or not (for type="checkbox" or type="radio"). -**`reducers`**: `array` | `function` +**`touched`**: boolean + +if *true* validation messages (sync and async) will be showing only when the event onBlur of the field is triggered by the user action. -*`(nextValue, prevValue, formState) => nextValue`* +**`reducers`**: array | function + +```javascript +(nextValue, prevValue, formState) => nextValue +``` An array whose values correspond to different reducing functions. Reducers functions specify how the Input's value change. @@ -72,7 +83,7 @@ Reducers functions specify how the Input's value change. } -## Validation +## Validation - Sync ```javascript import { useValidation } from 'usetheform' @@ -80,16 +91,63 @@ Reducers functions specify how the Input's value change. {() => { - const email = value => + const isValidEmail = value => !(value && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) ? undefined : "Mail not Valid"; const required = value => (value && value.trim() !== "" ? undefined : "Required"); - const [status, validation] = useValidation([required, email]); + const [status, validation] = useValidation([required, isValidEmail]); return ( {status.error && } + + + ) + } +} + + + +## Validation - Async + +```javascript +import { useAsyncValidation, useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +``` + + +{() => { + const asyncTest = value => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (value === "foo") { + reject("username already in use"); + } else { + resolve("Success"); + } + }, 1000); + }); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); + return ( +
+ + {asyncStatus.status === undefined && } + {asyncStatus.status === "asyncStart" && } + {asyncStatus.status === "asyncError" && } + {asyncStatus.status === "asyncSuccess" && } +
+ ) } diff --git a/docs/Select.mdx b/docs/Select.mdx index a096a31..aaf3eb1 100644 --- a/docs/Select.mdx +++ b/docs/Select.mdx @@ -5,7 +5,8 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; -import { Select, useValidation } from './../src'; +import { Submit } from "./helpers/Submit"; +import { Select, useValidation, useAsyncValidation } from './../src'; # Select The *select* element is used to create a drop-down list.
@@ -13,17 +14,27 @@ It accepts as props any html attribute listed at: [Html Select Attributes](https ### Props -**`name`**: `string` +**`name`**: string - (except for **Select** children of Collection of type array) A field's name in Usetheform state. -**`value`**: `string` +**`index`**: string - (only for **Select** children of Collection of type array) + +A field's index in array Collection. + +**`value`**: string Specifies the initial value of a *select* element. -**`reducers`**: `array` | `function` +**`touched`**: boolean + +if *true* validation messages (sync and async) will be showing only when the event onBlur of the field is triggered by the user action. + +**`reducers`**: array | function -*`(nextValue, prevValue, formState) => nextValue`* +```javascript +(nextValue, prevValue, formState) => nextValue +``` An array whose values correspond to different reducing functions. Reducers functions specify how the Select's value change. @@ -89,7 +100,7 @@ Reducers functions specify how the Select's value change. }
-## Validation +## Validation - Sync ```javascript import { useValidation } from 'usetheform' @@ -113,4 +124,53 @@ Reducers functions specify how the Select's value change. ) } } +
+ +## Validation - Async + +```javascript +import { useAsyncValidation, useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +``` + + +{() => { + const asyncTest = value => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (value !== "3") { + reject("Selection not valid"); + } else { + resolve("Success"); + } + }, 1000); + }); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); + return ( +
+ + {asyncStatus.status === undefined && } + {asyncStatus.status === "asyncStart" && } + {asyncStatus.status === "asyncError" && } + {asyncStatus.status === "asyncSuccess" && } + + + ) + } +}
\ No newline at end of file diff --git a/docs/TextArea.mdx b/docs/TextArea.mdx index f6cd487..4553f9b 100644 --- a/docs/TextArea.mdx +++ b/docs/TextArea.mdx @@ -5,25 +5,35 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; -import { TextArea, useValidation } from './../src'; +import { Submit } from "./helpers/Submit"; +import { TextArea, useValidation, useAsyncValidation } from './../src'; # TextArea It renders a *textarea* element: [W3schools Textarea](https://www.w3schools.com/tags/tag_textarea.asp) and accepts as props any html attribute listed at: [Html Textarea Attributes](https://www.w3schools.com/tags/tag_textarea.asp). ### Props -**`name`**: `string` +**`name`**: string - (except for **TextArea** children of Collection of type array) A field's name in Usetheform state. -**`value`**: `string` +**`index`**: string - (only for **TextArea** children of Collection of type array) + +A field's index in array Collection. + +**`value`**: string Specifies the initial value of an *textarea* element. +**`touched`**: boolean + +if *true* validation messages (sync and async) will be showing only when the event onBlur of the field is triggered by the user action. -**`reducers`**: `array` | `function` +**`reducers`**: array | function -*`(nextValue, prevValue, formState) => nextValue`* +```javascript +(nextValue, prevValue, formState) => nextValue +``` An array whose values correspond to different reducing functions. Reducers functions specify how the TextArea's value change. @@ -59,7 +69,7 @@ Reducers functions specify how the TextArea's value change. } -## Validation +## Validation - Sync ```javascript import { useValidation } from 'usetheform' @@ -81,4 +91,49 @@ Reducers functions specify how the TextArea's value change. ) } } + + +## Validation - Async + +```javascript +import { useAsyncValidation, useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +``` + + +{() => { + const asyncTest = value => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (value === "foo") { + reject("text not allowed"); + } else { + resolve("Success"); + } + }, 1000); + }); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); + return ( +
+