From c22fda5c9537aea47b1967cb52b16fe47f8bbcdb Mon Sep 17 00:00:00 2001 From: Antonio Pangallo Date: Tue, 26 Jan 2021 10:17:59 +0100 Subject: [PATCH] Enabling sync and async validation at form level (#29) Co-authored-by: Antonio --- .eslintrc | 5 + .travis.yml | 2 + README.md | 42 +- __tests__/Collection.spec.js | 58 +- ...ollectionArrayIndexHandledManually.spec.js | 128 + __tests__/Form.spec.js | 119 +- __tests__/FormAsyncValidation.spec.js | 217 + __tests__/FormContextAsyncValidation.spec.js | 184 + __tests__/FormContextSyncValidation.spec.js | 144 + __tests__/FormSyncValidation.spec.js | 144 + __tests__/Input.spec.js | 6 +- .../CollectionArrayIndexHandledManually.jsx | 134 + .../components/CollectionDynamicField.jsx | 40 + .../components/FormContextWithValidation.jsx | 40 + .../helpers/components/FormWithValidation.jsx | 30 + __tests__/helpers/components/Reset.jsx | 30 +- __tests__/helpers/utils/mountForm.js | 4 +- __tests__/helpers/utils/mountFormContext.js | 12 + docs/Collection.mdx | 97 +- docs/Form.mdx | 188 +- docs/FormContext.mdx | 148 +- docs/Input.mdx | 82 +- docs/Select.mdx | 2 +- docs/TextArea.mdx | 2 +- docs/helpers/Form.jsx | 12 +- docs/helpers/InputLabel.jsx | 26 + docs/helpers/Item.jsx | 48 + docs/helpers/utils/index.js | 26 + docs/index.mdx | 59 +- docs/useAsyncValidation.mdx | 9 +- docs/useCollection.mdx | 2 +- docs/useField.mdx | 2 +- docs/useForm.mdx | 70 +- docs/useSelector.mdx | 5 +- docs/useValidation.mdx | 5 +- .../helpers/CollectionNestedDynamicField.js | 87 + .../CollectionNestedDynamicFieldIndex.js | 99 + examples/helpers/FormContextWithValidation.js | 58 + examples/helpers/SimpleForm.js | 76 +- examples/index.html | 7 +- package-lock.json | 3568 +++++++++-------- package.json | 3 +- src/Form.js | 14 + src/FormContext.js | 14 + src/hooks/commons/useNameProp.js | 8 +- src/hooks/useField.js | 52 +- src/hooks/useForm.js | 95 +- src/hooks/useObject.js | 43 +- src/hooks/useValidators.js | 1 - src/utils/constants.js | 1 + src/utils/formUtils.js | 5 +- src/utils/validateProps.js | 73 + 52 files changed, 4261 insertions(+), 2065 deletions(-) create mode 100644 __tests__/CollectionArrayIndexHandledManually.spec.js create mode 100644 __tests__/FormAsyncValidation.spec.js create mode 100644 __tests__/FormContextAsyncValidation.spec.js create mode 100644 __tests__/FormContextSyncValidation.spec.js create mode 100644 __tests__/FormSyncValidation.spec.js create mode 100644 __tests__/helpers/components/CollectionArrayIndexHandledManually.jsx create mode 100644 __tests__/helpers/components/FormContextWithValidation.jsx create mode 100644 __tests__/helpers/components/FormWithValidation.jsx create mode 100644 __tests__/helpers/utils/mountFormContext.js create mode 100644 docs/helpers/InputLabel.jsx create mode 100644 docs/helpers/Item.jsx create mode 100644 docs/helpers/utils/index.js create mode 100644 examples/helpers/CollectionNestedDynamicField.js create mode 100644 examples/helpers/CollectionNestedDynamicFieldIndex.js create mode 100644 examples/helpers/FormContextWithValidation.js create mode 100644 src/utils/validateProps.js diff --git a/.eslintrc b/.eslintrc index 0346819..0657f79 100755 --- a/.eslintrc +++ b/.eslintrc @@ -33,5 +33,10 @@ "browser": true, "node": true, "jest": true + }, + "settings": { + "react": { + "version": "latest" + } } } diff --git a/.travis.yml b/.travis.yml index 847c541..21fb218 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,3 +17,5 @@ deploy: github_token: $GITHUB_TOKEN on: branch: master +after_success: +- npm run coveralls diff --git a/README.md b/README.md index df31efb..4f43cac 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@

An easy way for building forms in React.


+Code Coverage Build info Bundle size Tweet



-

@@ -19,25 +19,33 @@ ## :bulb: What is usetheform about? -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. +Welcome! 👋 Usetheform is a React library for composing declarative forms and managing their state. It does not depend on any external library like Redux, MobX or others, which makes it to be easily adoptedable without other dependencies. - [Documentation](https://iusehooks.github.io/usetheform/) -- [Installation](#Installation) +- [Features](#fire-features) +- [Quickstart](#zap-quickstart) +- [Motivation](#motivation) - [Code Sandboxes Examples](#code-sandboxes) +- [Contributing](#contributing) - [License](#license) -✅ Zero dependencies +## :fire: Features -✅ Only peer dependencies: React >= 16.8.0 +- Easy integration with other libraries. 👉🏻 [Play with React Select/Material UI](https://codesandbox.io/s/materialuireactselect-6ufc2) - [React Dropzone/MaterialUI Dropzone](https://codesandbox.io/s/reactdropzone-materialuidropzone-yjb8w). +- Support Sync and Async validation at [Form](https://iusehooks.github.io/usetheform/docs-form#validation---sync), [Field](https://iusehooks.github.io/usetheform/docs-input#validation---sync) and [Collection](https://iusehooks.github.io/usetheform/docs-collection#validation---sync) level. 👉🏻 [Play with Sync and Async validation](https://iusehooks.github.io/usetheform/docs-input#validation---sync). +- Support [Yup](https://codesandbox.io/s/schema-validations-uc1m6?file=/src/FormYUP.jsx), [Zod](https://codesandbox.io/s/schema-validations-uc1m6?file=/src/FormZOD.jsx), [Superstruct](https://codesandbox.io/s/schema-validations-uc1m6?file=/src/FormSuperStruct.jsx), [Joi](https://codesandbox.io/s/schema-validations-uc1m6?file=/src/FormJOI.jsx) or custom. 👉🏻 [Play with YUP - ZOD - Superstruct - Joi validations](https://codesandbox.io/s/schema-validations-uc1m6). +- Follows HTML standard for validation. 👉🏻 [Play with HTML built-in form validation](https://codesandbox.io/s/built-informvalidation-lp672?file=/src/Info.jsx). +- Support reducers functions at [Form](https://iusehooks.github.io/usetheform/docs-form#reducers), [Field](https://iusehooks.github.io/usetheform/docs-input#reducers) and [Collection](https://iusehooks.github.io/usetheform/docs-collection#reducers) level. 👉🏻 [Play with Reducers](https://iusehooks.github.io/usetheform/docs-form#reducers). +- Easy to handle arrays, objects or nested collections. 👉🏻 [Play with nested collections](https://iusehooks.github.io/usetheform/docs-collection#nested-collections). +- Tiny size with zero dependencies. 👉🏻 [Check size](https://bundlephobia.com/result?p=usetheform). +- Typescript supported. -## Installation +## :zap: Quickstart ```sh npm install --save usetheform ``` -## :zap: Quickstart - ```jsx import React from "react"; import Form, { Input, useValidation } from "usetheform"; @@ -64,6 +72,12 @@ export default function App() { } ``` +## Motivation + +**usetheform** has been built having in mind the necessity of developing a lightweight library able to provide an easy API to build complex forms composed by nested levels (arrays, objects, custom inputs, etc.) with a declarative approach and without the need to include external libraries within your react projects. + +It's easy to start using it in your existing project and gives you a full controll over Field, Collection at any level of nesting which makes easy to manipulate the form state based on your needs. Synchronous and asynchronous validations are simple and error messages easy to customize and display. If you find it useful please leave a star 🙏🏻. + ## Author - Antonio Pangallo [@antonio_pangall](https://twitter.com/antonio_pangall) @@ -73,10 +87,18 @@ export default function App() { - Twitter What's Happening Form Bar: [Sandbox](https://codesandbox.io/s/twitter-bar-form-czx3o) - 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) +- Validation using Yup, ZOD, JOI, Superstruct: [Sandbox](https://codesandbox.io/s/schema-validations-uc1m6) - Wizard: [Sandbox](https://codesandbox.io/s/v680xok7k7) - FormContext: [Sandbox](https://codesandbox.io/s/formcontext-ukvc5) -- Material UI - React Select: [Sandbox](https://codesandbox.io/s/materialuireactselect-6ufc2) +- Material UI - React Select: [Sandbox](https://codesandbox.io/s/materialuireactselect-6ufc2) +- React Dropzone - Material UI Dropzone: [Sandbox](https://codesandbox.io/s/reactdropzone-materialuidropzone-yjb8w) +- Various Implementation: [Sandbox](https://codesandbox.io/s/035l4l75ln) + +## Contributing + +🎉 First off, thanks for taking the time to contribute! 🎉 + +We would like to encourage everyone to help and support this library by contributing. See the [CONTRIBUTING file](https://github.com/iusehooks/usetheform/blob/master/CONTRIBUTING.md). ## License diff --git a/__tests__/Collection.spec.js b/__tests__/Collection.spec.js index 4c2f82f..8f1cab8 100644 --- a/__tests__/Collection.spec.js +++ b/__tests__/Collection.spec.js @@ -3,7 +3,10 @@ import { fireEvent, waitFor, cleanup, act } from "@testing-library/react"; import { Input, Collection } from "./../src"; -import { CollectionDynamicCart } from "./helpers/components/CollectionDynamicField"; +import { + CollectionDynamicCart, + CollectionObjectDynamicField +} from "./helpers/components/CollectionDynamicField"; import CollectionDynamicAdded from "./helpers/components/CollectionDynamicAdded"; import CollectionValidation, { CollectionValidationTouched @@ -582,6 +585,27 @@ describe("Component => Collection", () => { true ); }); + it("should add/remove fields dyncamically from a object Collection", () => { + const props = { onInit, onChange }; + const children = []; + + const { getByTestId } = mountForm({ children, props }); + + const addInput = getByTestId("addInput"); + const removeInput = getByTestId("removeInput"); + expect(onInit).toHaveBeenCalledWith({}, true); + act(() => { + fireEvent.click(addInput); + }); + + expect(onChange).toHaveBeenCalledWith({ dynamic: { 1: "1" } }, true); + + act(() => { + fireEvent.click(removeInput); + }); + + expect(onChange).toHaveBeenCalledWith({}, true); + }); it("should run reducer functions on Collection fields removal", () => { const props = { onSubmit, onChange, onReset }; @@ -653,4 +677,36 @@ describe("Component => Collection", () => { console.error = originalError; }); + + it("should throw an error if the a prop 'name' is used within an array Collection", () => { + const originalError = console.error; + console.error = jest.fn(); + let children = [ + + + + ]; + expect(() => mountForm({ children })).toThrowError( + /it is not allowed within context a of type \"array\"/i + ); + + console.error = originalError; + }); + + it("should throw an error for an invalid 'asyncValidator' prop", () => { + const originalError = console.error; + console.error = jest.fn(); + let children = [ + + + + + + ]; + expect(() => mountForm({ children })).toThrowError( + /It must be a function/i + ); + + console.error = originalError; + }); }); diff --git a/__tests__/CollectionArrayIndexHandledManually.spec.js b/__tests__/CollectionArrayIndexHandledManually.spec.js new file mode 100644 index 0000000..1be1465 --- /dev/null +++ b/__tests__/CollectionArrayIndexHandledManually.spec.js @@ -0,0 +1,128 @@ +import React from "react"; +import { fireEvent, cleanup, act } from "@testing-library/react"; + +import { CollectionArrayIndexHandledManually } from "./helpers/components/CollectionArrayIndexHandledManually"; + +import Reset from "./helpers/components/Reset"; +import Submit from "./helpers/components/Submit"; +import { mountForm } from "./helpers/utils/mountForm"; + +const onInit = jest.fn(); +const onChange = jest.fn(); +const onReset = jest.fn(); +const onSubmit = jest.fn(); + +afterEach(cleanup); + +describe("Component => Collection (Array with indexes handled manually)", () => { + beforeEach(() => { + onInit.mockClear(); + onChange.mockClear(); + onReset.mockClear(); + onSubmit.mockClear(); + }); + + it("should correctly render an array Collection with indexes handled manually", () => { + const props = { onInit, onChange, onReset, onSubmit }; + const myself = { current: null }; + + const children = [ + , + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const addInput = getByTestId("addInput"); + const addCollection = getByTestId("addCollection"); + const removeCollection = getByTestId("removeCollection"); + + const removeInput = getByTestId("removeInput"); + const reset = getByTestId("reset"); + const submit = getByTestId("submit"); + + expect(onInit).toHaveBeenCalledWith({}, true); + + for (let i = 1; i <= 10; i++) { + act(() => { + fireEvent.click(addInput); + }); + } + + let stateExpected = myself.current.getInnerState(); + expect(onChange).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + + for (let i = 1; i <= 5; i++) { + act(() => { + fireEvent.click(removeInput); + }); + } + + stateExpected = myself.current.getInnerState(); + expect(onChange).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + + const newExpected = []; + stateExpected[0].forEach(val => { + const input = getByTestId(`input_${val}`); + const newValue = Math.random() * 10000; + newExpected.push(`${newValue}`); + act(() => { + fireEvent.change(input, { target: { value: `${newValue}` } }); + }); + }); + + expect(onChange).toHaveBeenCalledWith({ indexManual: [newExpected] }, true); + + act(() => { + fireEvent.click(submit); + }); + + expect(onSubmit).toHaveBeenCalledWith({ indexManual: [newExpected] }, true); + + act(() => { + fireEvent.click(reset); + }); + + expect(onReset).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + + for (let i = 1; i <= 10; i++) { + act(() => { + fireEvent.click(addCollection); + }); + } + + stateExpected = myself.current.getInnerState(); + expect(onChange).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + + const newCollectionExpected = []; + stateExpected[1].forEach(val => { + const input = getByTestId(`text_${val[0]}`); + const newValue = Math.random() * 10000; + newCollectionExpected.push([`${newValue}`]); + act(() => { + fireEvent.change(input, { target: { value: `${newValue}` } }); + }); + }); + + const nextStateExpected = [stateExpected[0], newCollectionExpected]; + expect(onChange).toHaveBeenCalledWith( + { indexManual: nextStateExpected }, + true + ); + + stateExpected = myself.current.getInnerState(); + act(() => { + fireEvent.click(reset); + }); + + expect(onReset).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + + for (let i = 1; i <= 5; i++) { + act(() => { + fireEvent.click(removeCollection); + }); + } + + stateExpected = myself.current.getInnerState(); + expect(onChange).toHaveBeenCalledWith({ indexManual: stateExpected }, true); + }); +}); diff --git a/__tests__/Form.spec.js b/__tests__/Form.spec.js index dd4ac25..712b782 100644 --- a/__tests__/Form.spec.js +++ b/__tests__/Form.spec.js @@ -98,6 +98,60 @@ describe("Component => Form", () => { expect(onInit).toHaveReturnedWith(initialState); }); + it("should onInit Form callback called only once", () => { + const props = { onInit, onChange }; + const { getByTestId } = render(); + const textField = getByTestId("name"); + + expect(onInit).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.change(textField, { target: { value: "Antonio" } }); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInit).toHaveBeenCalledTimes(1); + }); + + it("should onReset callback called only once", () => { + const props = { onReset, onChange }; + const { getByTestId } = render(); + const textField = getByTestId("name"); + const reset = getByTestId("reset"); + + act(() => { + fireEvent.change(textField, { target: { value: "Antonio" } }); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.click(reset); + }); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it("should test 'pristine' value after resetting form", () => { + const { getByTestId } = render(); + const textField = getByTestId("name"); + const reset = getByTestId("reset"); + + expect(() => getByTestId("pristine")).not.toThrow(); + + act(() => { + fireEvent.change(textField, { target: { value: "Antonio" } }); + }); + + expect(() => getByTestId("pristine")).toThrow(); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("pristine")).not.toThrow(); + }); + it("should override a initialized the Form state if Fields contain the value prop", () => { const initialState = { text: "foo", @@ -625,17 +679,42 @@ describe("Component => Form", () => { 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 `preventDefault` Form submission if action props is present and Async validation is applied at any level", async () => { + const originalError = console.error; + console.error = jest.fn(); + const initialState = { username: "BeBo" }; + const props = { + onSubmit, initialState, action: "http://yourapiserver.com/submit" }; - const { getByTestId } = render(); + const { getByTestId } = render(); + + const asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + const submitbutton = getByTestId("submit"); + expect(submitbutton.disabled).toBe(false); + const form = getByTestId("form"); + form.submit = jest.fn(); const isNotPrevented = fireEvent.submit(form); + expect(isNotPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); - expect(isNotPrevented).toBe(true); console.error = originalError; }); @@ -702,17 +781,37 @@ describe("Component => Form", () => { console.error = originalError; }); - it("should button being disabled for an a invalid Form with Async Fields validators functions", async () => { + it("should button being enabled for a valid Form with Async Fields validators functions", async () => { const originalError = console.error; console.error = jest.fn(); - const initialState = { - username: "foo" - }; + const initialState = { username: "abcde" }; - const props = { - initialState, - onSubmit - }; + const props = { initialState, onSubmit }; + + const { getByTestId } = render(); + + const asyncinput = getByTestId("asyncinput"); + expect(asyncinput.value).toBe(initialState.username); + + const asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + const asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + + expect(() => getByTestId("asyncError")).toThrow(); + + const submitbutton = getByTestId("submit"); + expect(submitbutton.disabled).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"); diff --git a/__tests__/FormAsyncValidation.spec.js b/__tests__/FormAsyncValidation.spec.js new file mode 100644 index 0000000..9fc05a4 --- /dev/null +++ b/__tests__/FormAsyncValidation.spec.js @@ -0,0 +1,217 @@ +import React from "react"; +import { + cleanup, + fireEvent, + act, + waitFor, + render +} from "@testing-library/react"; + +import Reset from "./helpers/components/Reset"; +import Submit from "./helpers/components/Submit"; +import SimpleFormWithAsync from "./helpers/components/SimpleFormWithAsync"; +import { mountForm } from "./helpers/utils/mountForm"; + +import { Input, Collection } from "./../src"; + +afterEach(cleanup); + +describe("Component => Form (Async validation)", () => { + it("should asynchronously validate a Form", async () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { asyncValidatorFunc: isValidEmail }; + const children = [ + , + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + const submit = getByTestId("submit"); + + expect(() => getByTestId("asyncStart")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + fireEvent.click(submit); + }); + + let asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value: "bademail#live.it" } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncError = await waitFor(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + expect(asyncError.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + }); + + it("should asynchronously validate a nested Form with Collection", async () => { + const value = "bebo@test.it"; + const props = { asyncValidatorFunc: isValidEmailNested }; + const children = [ + + + + + + , + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const emailInput1 = getByTestId("email1"); + const emailInput2 = getByTestId("email2"); + const reset = getByTestId("reset"); + const submit = getByTestId("submit"); + + expect(() => getByTestId("asyncStart")).toThrow(); + + act(() => { + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.click(submit); + }); + + let asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + expect(asyncStart.innerHTML).toBe("Checking..."); + + let asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput2, { target: { value: "bademail#live.it" } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncError = await waitFor(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + expect(asyncError.innerHTML).toBe("Some Mails not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + }); + + it("should asynchronously validate Form with muliple editing on same field", async () => { + const initialState = { username: "1234" }; + + const props = { initialState }; + const { getByTestId } = render(); + const asyncinput = getByTestId("asyncinput"); + + const asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + + act(() => { + fireEvent.change(asyncinput, { target: { value: "423456" } }); + asyncinput.focus(); + asyncinput.blur(); + }); + + act(() => { + fireEvent.change(asyncinput, { target: { value: "123" } }); + asyncinput.focus(); + asyncinput.blur(); + }); + + const asyncError = await waitFor(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + }); +}); + +function isValidEmail({ email }) { + return new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + const isValid = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email); + if (!isValid) { + reject("Mail not Valid"); + } else { + resolve("Success"); + } + }, 1000); + }); +} + +function isValidEmailNested({ user }) { + return new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (!user?.mailList?.length || user?.mailList?.length <= 0) { + reject("Mail list empty"); + } + const isValid = user?.mailList?.every?.(email => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ); + if (!isValid) { + reject("Some Mails not Valid"); + } else { + resolve("Success"); + } + }, 1000); + }); +} diff --git a/__tests__/FormContextAsyncValidation.spec.js b/__tests__/FormContextAsyncValidation.spec.js new file mode 100644 index 0000000..457e39a --- /dev/null +++ b/__tests__/FormContextAsyncValidation.spec.js @@ -0,0 +1,184 @@ +import React from "react"; +import { cleanup, fireEvent, act, waitFor } from "@testing-library/react"; + +import Reset from "./helpers/components/Reset"; +import Submit from "./helpers/components/Submit"; +import { mountFormContext } from "./helpers/utils/mountFormContext"; + +import { Input, Collection } from "./../src"; + +afterEach(cleanup); + +describe("Component => FormContext (Async validation)", () => { + it("should asynchronously validate a FormContext", async () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { asyncValidatorFunc: isValidEmail }; + const children = [ + , + , + + ]; + const { getByTestId } = mountFormContext({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + const submit = getByTestId("submit"); + + expect(() => getByTestId("asyncStart")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + fireEvent.click(submit); + }); + + let asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value: "bademail#live.it" } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncError = await waitFor(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + expect(asyncError.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + }); + + it("should asynchronously validate a nested FormContext with Collection", async () => { + const value = "bebo@test.it"; + const props = { asyncValidatorFunc: isValidEmailNested }; + const children = [ + + + + + + , + , + + ]; + const { getByTestId } = mountFormContext({ props, children }); + const emailInput1 = getByTestId("email1"); + const emailInput2 = getByTestId("email2"); + const reset = getByTestId("reset"); + const submit = getByTestId("submit"); + + expect(() => getByTestId("asyncStart")).toThrow(); + + act(() => { + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.click(submit); + }); + + let asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + + expect(asyncStart.innerHTML).toBe("Checking..."); + + let asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput2, { target: { value: "bademail#live.it" } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + const asyncError = await waitFor(() => getByTestId("asyncError")); + expect(asyncError).toBeDefined(); + expect(asyncError.innerHTML).toBe("Some Mails not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("asyncStart")).toThrow(); + expect(() => getByTestId("asyncSuccess")).toThrow(); + expect(() => getByTestId("asyncError")).toThrow(); + + act(() => { + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.change(emailInput1, { target: { value } }); + fireEvent.click(submit); + }); + + asyncStart = await waitFor(() => getByTestId("asyncStart")); + expect(asyncStart).toBeDefined(); + expect(asyncStart.innerHTML).toBe("Checking..."); + + asyncSuccess = await waitFor(() => getByTestId("asyncSuccess")); + expect(asyncSuccess).toBeDefined(); + expect(asyncSuccess.innerHTML).toBe("Success"); + }); +}); + +function isValidEmail({ email }) { + return new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + const isValid = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email); + if (!isValid) { + reject("Mail not Valid"); + } else { + resolve("Success"); + } + }, 1000); + }); +} + +function isValidEmailNested({ user }) { + return new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (!user?.mailList?.length || user?.mailList?.length <= 0) { + reject("Mail list empty"); + } + const isValid = user?.mailList?.every?.(email => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ); + if (!isValid) { + reject("Some Mails not Valid"); + } else { + resolve("Success"); + } + }, 1000); + }); +} diff --git a/__tests__/FormContextSyncValidation.spec.js b/__tests__/FormContextSyncValidation.spec.js new file mode 100644 index 0000000..deed407 --- /dev/null +++ b/__tests__/FormContextSyncValidation.spec.js @@ -0,0 +1,144 @@ +import React from "react"; +import { cleanup, fireEvent, act } from "@testing-library/react"; + +import Reset from "./helpers/components/Reset"; +import { mountFormContext } from "./helpers/utils/mountFormContext"; + +import { Input, Collection } from "./../src"; + +afterEach(cleanup); + +describe("Component => FormContext (sync validation)", () => { + it("should synchronously validate a FormContext with touched prop false", () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { validators: [isValidEmail] }; + const children = [ + , + + ]; + const { getByTestId } = mountFormContext({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + fireEvent.click(reset); + }); + + errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + }); + + it("should synchronously validate a FormContext with touched prop true", () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { validators: [isValidEmail], touched: true }; + const children = [ + , + + ]; + const { getByTestId } = mountFormContext({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput.focus(); + emailInput.blur(); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + }); + + it("should synchronously validate a nested FormContext with touched prop true", () => { + const value = "bebo@test.it"; + const props = { validators: [isArrayOfMailValid], touched: true }; + const children = [ + + + + + + , + + ]; + const { getByTestId } = mountFormContext({ props, children }); + const emailInput1 = getByTestId("email1"); + const emailInput2 = getByTestId("email2"); + const reset = getByTestId("reset"); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput2.focus(); + emailInput2.blur(); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail list empty"); + + act(() => { + emailInput2.focus(); + fireEvent.change(emailInput2, { target: { value } }); + emailInput2.blur(); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput1.focus(); + fireEvent.change(emailInput1, { target: { value: "invalid@test" } }); + emailInput1.blur(); + }); + + errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Some Mails not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + }); +}); + +function isValidEmail({ email }) { + return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ? undefined + : "Mail not Valid"; +} + +function isArrayOfMailValid({ user }) { + if (!user?.mailList?.length || user?.mailList?.length <= 0) { + return "Mail list empty"; + } + + return user?.mailList?.every?.(email => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ) + ? undefined + : "Some Mails not Valid"; +} diff --git a/__tests__/FormSyncValidation.spec.js b/__tests__/FormSyncValidation.spec.js new file mode 100644 index 0000000..6514349 --- /dev/null +++ b/__tests__/FormSyncValidation.spec.js @@ -0,0 +1,144 @@ +import React from "react"; +import { cleanup, fireEvent, act } from "@testing-library/react"; + +import Reset from "./helpers/components/Reset"; +import { mountForm } from "./helpers/utils/mountForm"; + +import { Input, Collection } from "./../src"; + +afterEach(cleanup); + +describe("Component => Form (sync validation)", () => { + it("should synchronously validate a Form with touched prop false", () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { validators: [isValidEmail] }; + const children = [ + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + fireEvent.click(reset); + }); + + errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + }); + + it("should synchronously validate a Form with touched prop true", () => { + const name = "email"; + const value = "bebo@test.it"; + const props = { validators: [isValidEmail], touched: true }; + const children = [ + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const emailInput = getByTestId(name); + const reset = getByTestId("reset"); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput.focus(); + emailInput.blur(); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + fireEvent.change(emailInput, { target: { value } }); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + }); + + it("should synchronously validate a nested Form with touched prop true", () => { + const value = "bebo@test.it"; + const props = { validators: [isArrayOfMailValid], touched: true }; + const children = [ + + + + + + , + + ]; + const { getByTestId } = mountForm({ props, children }); + const emailInput1 = getByTestId("email1"); + const emailInput2 = getByTestId("email2"); + const reset = getByTestId("reset"); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput2.focus(); + emailInput2.blur(); + }); + + let errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Mail list empty"); + + act(() => { + emailInput2.focus(); + fireEvent.change(emailInput2, { target: { value } }); + emailInput2.blur(); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + + act(() => { + emailInput1.focus(); + fireEvent.change(emailInput1, { target: { value: "invalid@test" } }); + emailInput1.blur(); + }); + + errorLabel = getByTestId("errorLabel"); + expect(errorLabel.innerHTML).toBe("Some Mails not Valid"); + + act(() => { + fireEvent.click(reset); + }); + + expect(() => getByTestId("errorLabel")).toThrow(); + }); +}); + +function isValidEmail({ email }) { + return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ? undefined + : "Mail not Valid"; +} + +function isArrayOfMailValid({ user }) { + if (!user?.mailList?.length || user?.mailList?.length <= 0) { + return "Mail list empty"; + } + + return user?.mailList?.every?.(email => + /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email) + ) + ? undefined + : "Some Mails not Valid"; +} diff --git a/__tests__/Input.spec.js b/__tests__/Input.spec.js index feecf06..5124813 100644 --- a/__tests__/Input.spec.js +++ b/__tests__/Input.spec.js @@ -700,17 +700,17 @@ describe("Component => Input", () => { console.error = jest.fn(); let children = []; expect(() => mountForm({ children })).toThrowError( - /Input of type => radio, must have a valid prop "value"./i + / of type => radio, must have a valid prop "value"./i ); children = []; expect(() => mountForm({ children })).toThrowError( - /Input of type => radio, must have a valid prop "value"./i + / of type => radio, must have a valid prop "value"./i ); children = []; expect(() => mountForm({ children })).toThrowError( - /Input of type => radio, must have a valid prop "value"./i + / of type => radio, must have a valid prop "value"./i ); console.error = originalError; diff --git a/__tests__/helpers/components/CollectionArrayIndexHandledManually.jsx b/__tests__/helpers/components/CollectionArrayIndexHandledManually.jsx new file mode 100644 index 0000000..274512c --- /dev/null +++ b/__tests__/helpers/components/CollectionArrayIndexHandledManually.jsx @@ -0,0 +1,134 @@ +import React, { + useRef, + useState, + useImperativeHandle, + forwardRef +} from "react"; +import { Input, Collection } from "./../../../src"; + +export const CollectionArrayIndexHandledManually = forwardRef((props, ref) => { + const { name = "indexManual" } = props; + const innerState = useRef([]); + + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const indexCollection = useRef(0); + const [collections, setCollection] = useState([]); + + const addInput = () => { + const myIndex = ++index.current; + if (!innerState.current[0]) { + innerState.current[0] = []; + } + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + const nextVal = [...prev]; + const value = `${myIndex}`; + const nextInneState = [...innerState.current[0]]; + nextInneState.splice(pos, 0, value); + innerState.current[0] = nextInneState; + nextVal.splice(pos, 0, { + value, + key: value + }); + return nextVal; + }); + }; + + const removeInput = () => + setAdd(prev => { + const pos = Math.floor(Math.random() * prev.length); + innerState.current[0] = innerState.current[0].filter( + (val, index) => index !== pos + ); + if (innerState.current[0].length === 0) { + delete innerState.current[0]; + } + return prev.filter((elm, index) => index !== pos); + }); + + const addCollection = () => { + const myIndex = ++indexCollection.current; + if (!innerState.current[1]) { + innerState.current[1] = []; + } + + setCollection(prev => { + const pos = Math.floor(Math.random() * prev.length); + const value = `${myIndex}`; + const nextVal = [...prev]; + const nextInneState = [...innerState.current[1]]; + nextInneState.splice(pos, 0, [value]); + innerState.current[1] = nextInneState; + nextVal.splice(pos, 0, { value: [value], key: value }); + + return nextVal; + }); + }; + + const removeCollection = () => { + setCollection(prev => { + const pos = Math.floor(Math.random() * prev.length); + + innerState.current[1] = innerState.current[1].filter( + (val, index) => index !== pos + ); + if (innerState.current[1].length === 0) { + delete innerState.current[1]; + } + + return prev.filter((elm, index) => index !== pos); + }); + }; + + useImperativeHandle(ref, () => ({ + getInnerState() { + return innerState.current; + } + })); + + return ( +

+ + + {inputs.map((inp, index) => ( + + ))} + +
+
+ + {collections.map((coll, index) => ( + + + + ))} + +
+
+ + + + +
+ ); +}); diff --git a/__tests__/helpers/components/CollectionDynamicField.jsx b/__tests__/helpers/components/CollectionDynamicField.jsx index 1e46080..0ff81bc 100644 --- a/__tests__/helpers/components/CollectionDynamicField.jsx +++ b/__tests__/helpers/components/CollectionDynamicField.jsx @@ -49,6 +49,46 @@ export function CollectionDynamicField({ name = "dynamic", reducers }) { ); } +export function CollectionObjectDynamicField({ name = "dynamic" }) { + const index = useRef(0); + const [inputs, setAdd] = useState([]); + + const addInput = () => { + index.current++; + setAdd(prev => [ + ...prev, + + ]); + }; + + const removeInput = () => setAdd(prev => prev.slice(0, -1)); + + 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([]); diff --git a/__tests__/helpers/components/FormContextWithValidation.jsx b/__tests__/helpers/components/FormContextWithValidation.jsx new file mode 100644 index 0000000..51f28d7 --- /dev/null +++ b/__tests__/helpers/components/FormContextWithValidation.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import { + FormContext, + useValidation, + useAsyncValidation, + useForm +} from "./../../../src"; + +export const FormContextWithValidation = ({ + validators, + asyncValidatorFunc, + children, + ...restProp +}) => { + const [status, formValidationProp] = useValidation(validators); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncValidatorFunc); + + return ( + <> + +
{children}
+
+ {status.error && } + {asyncStatus.status === "asyncStart" && ( + + )} + {asyncStatus.status === "asyncSuccess" && ( + + )} + {asyncStatus.status === "asyncError" && ( + + )} + + ); +}; + +function Form({ children }) { + const { onSubmitForm } = useForm(); + return
{children}
; +} diff --git a/__tests__/helpers/components/FormWithValidation.jsx b/__tests__/helpers/components/FormWithValidation.jsx new file mode 100644 index 0000000..f74b23b --- /dev/null +++ b/__tests__/helpers/components/FormWithValidation.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Form, useValidation, useAsyncValidation } from "./../../../src"; + +export const FormWithValidation = ({ + validators, + asyncValidatorFunc, + children, + ...restProp +}) => { + const [status, formValidationProp] = useValidation(validators); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncValidatorFunc); + + return ( + <> +
+ {children} +
+ {status.error && } + {asyncStatus.status === "asyncStart" && ( + + )} + {asyncStatus.status === "asyncSuccess" && ( + + )} + {asyncStatus.status === "asyncError" && ( + + )} + + ); +}; diff --git a/__tests__/helpers/components/Reset.jsx b/__tests__/helpers/components/Reset.jsx index afc1293..518bcf9 100644 --- a/__tests__/helpers/components/Reset.jsx +++ b/__tests__/helpers/components/Reset.jsx @@ -1,21 +1,25 @@ import React from "react"; import { useForm, STATUS } from "./../../../src"; -const Reset = () => { +const Reset = ({ forceEnable }) => { const { reset, pristine, formStatus } = useForm(); + const isDisabled = forceEnable + ? false + : pristine || + formStatus === STATUS.ON_INIT_ASYNC || + formStatus === STATUS.ON_RUN_ASYNC; return ( - + <> + + {pristine && } + ); }; diff --git a/__tests__/helpers/utils/mountForm.js b/__tests__/helpers/utils/mountForm.js index e8c5e8d..acb5b4a 100644 --- a/__tests__/helpers/utils/mountForm.js +++ b/__tests__/helpers/utils/mountForm.js @@ -1,10 +1,10 @@ import React from "react"; import { render } from "@testing-library/react"; -import { Form } from "./../../../src"; +import { FormWithValidation } from "./../components/FormWithValidation"; export const mountForm = ({ props = {}, children } = {}) => render( -
{children}
+ {children}
); diff --git a/__tests__/helpers/utils/mountFormContext.js b/__tests__/helpers/utils/mountFormContext.js new file mode 100644 index 0000000..1832233 --- /dev/null +++ b/__tests__/helpers/utils/mountFormContext.js @@ -0,0 +1,12 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { FormContextWithValidation } from "./../components/FormContextWithValidation"; + +export const mountFormContext = ({ props = {}, children } = {}) => + render( + + + {children} + + + ); diff --git a/docs/Collection.mdx b/docs/Collection.mdx index db3c952..048bcc4 100644 --- a/docs/Collection.mdx +++ b/docs/Collection.mdx @@ -6,8 +6,9 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; import { Submit } from "./helpers/Submit"; +import { InputLabel as Input } from "./helpers/InputLabel"; import { CustomInput } from "./helpers/CustomInput" -import { Collection, useValidation, Input, useAsyncValidation } from './../src'; +import { Collection, useValidation, useAsyncValidation } from './../src'; # Collection @@ -45,13 +46,8 @@ The async validation messages will be showing only at form submission. ```javascript const [status, validation] = useValidation([anyValidationFunc]) - - + + {status.error && } ``` @@ -77,12 +73,13 @@ Reducers functions specify how the Collection's value change.
- - - - - - + + + + + + + @@ -103,16 +100,16 @@ Reducers functions specify how the Collection's value change. - - + + - - + + - - + + @@ -128,12 +125,12 @@ import Form { Input, Collection } from "usetheform"; ``` -
- - - - -
+
+ + + + +

@@ -166,18 +163,12 @@ Array Collection of Input fields with indexes handled maunally. ``` -
- - - - -
+
+ + + + +
## Reducers @@ -198,9 +189,9 @@ Array Collection of Input fields with indexes handled maunally. return (
- - - + + +
) @@ -210,7 +201,7 @@ Array Collection of Input fields with indexes handled maunally. ## Validation - Sync -Validation for Collection starts only on form submission. +Validation at Collection level starts only on form submission if the prop **`touched`** is false. ```javascript import Form, { Input, Collection, useValidation } from 'usetheform' @@ -220,11 +211,13 @@ Validation for Collection starts only on form submission. {() => { const graterThan10 = value => ((value && (value["A"] + value["B"] > 10)) ? undefined : "A+B must be > 10"); const [status, validation] = useValidation([graterThan10]); + const onSubmit = (state) => alert(JSON.stringify(state)); + return ( -
+ - - + + {status.error && } @@ -237,6 +230,7 @@ Validation for Collection starts only on form submission. ## Validation - Async Async Validation for Collections are triggered on Sumbit event, the form submission is prevented if the validation fails. +It means that the onSubmit function passed as prop to the **Form** component will not be invoked. ```javascript import { useAsyncValidation, useForm } from 'usetheform' @@ -265,14 +259,15 @@ const Submit = () => { } }, 1000); }); - const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTest); + const [asyncStatus, validationProps] = useAsyncValidation(asyncTest); + const onSubmit = (state) => alert(JSON.stringify(state)); return ( - - - - + + + + - {asyncStatus.status === undefined && } + {asyncStatus.status === undefined && } {asyncStatus.status === "asyncStart" && } {asyncStatus.status === "asyncError" && } {asyncStatus.status === "asyncSuccess" && } diff --git a/docs/Form.mdx b/docs/Form.mdx index f59eaec..a9b9a9f 100644 --- a/docs/Form.mdx +++ b/docs/Form.mdx @@ -5,7 +5,11 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; -import { Input } from './../src'; +import { Submit } from "./helpers/Submit"; +import { asyncTestForm } from "./helpers/utils/index.js"; +import { InputLabel as Input } from "./helpers/InputLabel"; +import { Item, recudeTotalPrice, recudeTotalQuantity } from "./helpers/Item"; +import { useValidation, useAsyncValidation, Collection } from './../src'; # Form The Form is the most important component in Usetheform. It renders all the Fields and keeps synchronized the entire form state. @@ -30,7 +34,7 @@ const onChange = (formState, isFormValid) => { // some operation } **`onReset`**: function -A function invoked when the form has been reset to its initial State. +A function invoked when the form has been resetted to its initial state. ```javascript const onReset = (formState, isFormValid) => { // some operation } @@ -72,14 +76,20 @@ It is a plain object that rappresent the initial state of the form. An array whose values correspond to different reducing functions. Reducers functions specify how the Form's state change. +**`touched`**: boolean + +Default value *false*. + +If *true* sync validation messages will be showing only when the event onBlur of any forms's field is triggered by the user action at any level of nesting. + **`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") + - An absolute URL - points to another web site (like action="http://www.example.com/example.html") + - A relative URL - points to a file within a web site (like action="example.html") **`innerRef`**: object (a mutable ref object) @@ -99,25 +109,27 @@ A simple form with the initial state passed as Form prop. ```javascript import Form, { Input } from 'usetheform' ``` + console.log("INIT", state, isFormValid)} onChange={(state, isFormValid) => console.log("CHANGE", state, isFormValid)} - onSubmit={(state, isFormValid) => console.log("SUBMIT", state, isFormValid)} + onSubmit={(state) => alert(JSON.stringify(state))} > - - - - - + + + + + + ### Example 2 -A simple form with the initial state passed straight to the Form's Field. +A simple form with the initial state passed straight to the Form's Field. ```javascript import Form, { Input } from 'usetheform' @@ -126,13 +138,14 @@ A simple form with the initial state passed straight to the Form's Field.
console.log("INIT", state, isFormValid)} onChange={(state, isFormValid) => console.log("CHANGE", state, isFormValid)} - onSubmit={(state, isFormValid) => console.log("SUBMIT", state, isFormValid)} + onSubmit={(state) => alert(JSON.stringify(state))} > - - - - - + + + + + +
@@ -140,28 +153,139 @@ A simple form with the initial state passed straight to the Form's Field. ## Reducers ```javascript - import Form, { Input } from 'usetheform' +import Form, { Collection } from 'usetheform'; +import { Item } from './components/Item'; +import { recudeTotalPrice, recudeTotalQuantity } from './components/Item/utils'; +``` + +
+ + + + + { /* try to copy and paste a new item within the items Collection */ } + + + +
+
+ +#### Detailed Explanation: + +```javascript +export const Item = ({ price, qty }) => { + return ( + + + + + ); +} + +export const recudeTotalPrice = formState => { + const { items = [] } = formState; + const totalPrice = items.reduce((total, { price = 0, qty = 0 }) => { + total += price * qty; + return total; + }, 0); + return { ...formState, totalPrice }; +}; + +export const recudeTotalQuantity = (formState) => { + const { items = [] } = formState; + const totalQuantity = items.reduce((total, { qty = 0 }) => { + total += qty; + return total; + }, 0); + return { ...formState, totalQuantity }; +}; +``` + +## Validation - Sync + +Validation at Form level: + +- **touched=false**: errors messages will be showing on Form initialization and when any Field is edited. +- **touched=true**: errors messages will be showing when any Field at any level of nesting is touched/visited. + +```javascript + import Form, { Input, Collection, useValidation } from 'usetheform' ``` {() => { - const maxNumber10 = (nextState, prevState) => { - if (nextState.myNumber > 10) { - nextState.myNumber = 10; - } - return nextState; - }; - const minNumber1 = (nextState, prevState) => { - if (nextState.myNumber <= 0) { - nextState.myNumber = 0; - } - return nextState; - }; + const graterThan10 = ({ values }) => ((values && (values["A"] + values["B"] > 10)) ? undefined : "A+B must be > 10"); + const [status, validationProps] = useValidation([graterThan10]); return ( -
- + + + + + + {status.error && } +
) } }
+ +## Validation - Async + +Async Validation for **Form** is triggered on Sumbit event. The form submission is prevented if the validation fails. +It means that the onSubmit function passed as prop to the **Form** component will not be invoked. + +```javascript +import { Form, Collection, Input, useAsyncValidation } from 'usetheform'; +``` + + +{() => { + const [asyncStatus, validationProps] = useAsyncValidation(asyncTestForm); + const onSubmit = (state) => alert(JSON.stringify(state)); + return ( +
+ + + + + {asyncStatus.status === undefined && } + {asyncStatus.status === "asyncStart" && } + {asyncStatus.status === "asyncError" && } + {asyncStatus.status === "asyncSuccess" && } + + + ) + } +} +
+ +#### Detailed Explanation: + +```javascript +import { useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +export const asyncTestForm = ({ values }) => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (!values || !values.a || !values.b) { + reject("Emtpy values are not allowed "); + } + if (values.a + values.b >= 5) { + reject("The sum must be less than '5'"); + } else { + resolve("Success"); + } + }, 1000); + }); +``` diff --git a/docs/FormContext.mdx b/docs/FormContext.mdx index e7fe685..83bbcda 100644 --- a/docs/FormContext.mdx +++ b/docs/FormContext.mdx @@ -5,7 +5,10 @@ menu: Components import { Playground } from 'docz'; import { FormContext, Form } from "./helpers/FormContext"; -import { Input, useForm } from './../src'; +import { asyncTestForm } from "./helpers/utils/index.js"; +import { Submit } from "./helpers/Submit"; +import { InputLabel as Input } from "./helpers/InputLabel"; +import { useForm, useSelector, Collection, useValidation, useAsyncValidation } from './../src'; # FormContext @@ -73,6 +76,12 @@ It is a plain object that rappresent the initial state of the form. An array whose values correspond to different reducing functions. Reducers functions specify how the Form's state change. +**`touched`**: boolean + +Default value *false*. + +If *true* sync validation messages will be showing only when the event onBlur of any forms's field is triggered by the user action at any level of nesting. + ## Basic usage ```js @@ -89,32 +98,28 @@ export const Form = ({ children }) => { {() => { - const ResetName = () => { - const { dispatch } = useForm(); - const resetName = () => { - dispatch(prev => { - const { name: omitName, ...newState } = prev; - return newState; - }) - } - return ( - - ) + )); } + const onSubmit = (state) => alert(JSON.stringify(state)); return ( - console.log("INIT", state, isFormValid)} - onChange={(state, isFormValid) => console.log("CHANGE", state, isFormValid)} - onSubmit={(state, isFormValid) => console.log("SUBMIT", state, isFormValid)} - > - +
- + + + + +
+
); } @@ -136,18 +141,113 @@ export const Form = ({ children }) => { return nextState; }; const minNumber1 = (nextState, prevState) => { - if (nextState.myNumber <= 0) { - nextState.myNumber = 0; + if (nextState.myNumber <= 1) { + nextState.myNumber = 1; } return nextState; }; return (
- + +
+
+ ) + } +} +
+ + +## Validation - Sync + +Validation at FormContext level: + +- **touched=false**: errors messages will be showing on FormContext initialization and when any Field is edited. +- **touched=true**: errors messages will be showing when any Field at any level of nesting is touched/visited. + +```javascript + import { FormContext, Input, Collection, useValidation } from 'usetheform' +``` + + +{() => { + const graterThan10 = ({ values }) => ((values && (values["A"] + values["B"] > 10)) ? undefined : "A+B must be > 10"); + const [status, validationProps] = useValidation([graterThan10]); + return ( + +
+ + + + + {status.error && } +
) } }
+ +## Validation - Async + +Async Validation for **FormContext** is triggered on Sumbit event. The form submission is prevented if the validation fails. +It means that the onSubmit function passed as prop to the **FormContext** component will not be invoked. + +```javascript +import { FormContext, Collection, Input, useAsyncValidation } from 'usetheform'; +``` + + +{() => { + const [asyncStatus, validationProps] = useAsyncValidation(asyncTestForm); + const onSubmit = (state) => alert(JSON.stringify(state)); + return ( + +
+ + + + + {asyncStatus.status === undefined && } + {asyncStatus.status === "asyncStart" && } + {asyncStatus.status === "asyncError" && } + {asyncStatus.status === "asyncSuccess" && } + + +
+ ) + } +} +
+ +#### Detailed Explanation: + +```javascript +import { useForm } from 'usetheform' + +const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; + +export const asyncTestForm = ({ values }) => + new Promise((resolve, reject) => { + // it could be an API call or any async operation + setTimeout(() => { + if (!values || !values.a || !values.b) { + reject("Emtpy values are not allowed "); + } + if (values.a + values.b >= 5) { + reject("The sum must be less than '5'"); + } else { + resolve("Success"); + } + }, 1000); + }); +``` + diff --git a/docs/Input.mdx b/docs/Input.mdx index b34caa6..136b563 100644 --- a/docs/Input.mdx +++ b/docs/Input.mdx @@ -6,7 +6,9 @@ menu: Components import { Playground } from 'docz'; import { Form } from "./helpers/Form"; import { Submit } from "./helpers/Submit"; -import { Input, useValidation, useAsyncValidation } from './../src'; +import { asyncTestInput } from "./helpers/utils/index.js"; +import { InputLabel as Input } from "./helpers/InputLabel"; +import { 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). @@ -64,14 +66,14 @@ const ref = useRef(null) ```javascript import Form, { Input, Collection } from 'usetheform' ``` -
- - - - - + + + + + +
@@ -84,11 +86,11 @@ const ref = useRef(null) {() => { - const maxNumber10 = (nextValue, prevValue) => nextValue > 10 ? prevValue : nextValue; - const minNumber1 = (nextValue, prevValue) => nextValue <= 0 ? prevValue : nextValue; + const prevNumberGreater10 = (nextValue, prevValue) => nextValue > 10 ? prevValue : nextValue; + const prevNumberLessThan1 = (nextValue, prevValue) => nextValue <= 0 ? prevValue : nextValue; return (
- +
) } @@ -111,7 +113,7 @@ const ref = useRef(null) const [status, validation] = useValidation([required, isValidEmail]); return (
- + {status.error && } @@ -124,37 +126,16 @@ const ref = useRef(null) ## Validation - Async ```javascript -import { useAsyncValidation, useForm } from 'usetheform' - -const Submit = () => { - const { isValid } = useForm(); - return ( - - ); -}; - +import { Form, Input } from 'usetheform'; ``` {() => { - 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); + const [asyncStatus, asyncValidation] = useAsyncValidation(asyncTestInput); return (
- - {asyncStatus.status === undefined && } + + {asyncStatus.status === undefined && } {asyncStatus.status === "asyncStart" && } {asyncStatus.status === "asyncError" && } {asyncStatus.status === "asyncSuccess" && } @@ -164,4 +145,31 @@ const Submit = () => { ) } } - \ No newline at end of file + + +#### Detailed Explanation: + +```javascript +import { useForm } from 'usetheform' + +export const asyncTestInput = 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); + }); + +export const Submit = () => { + const { isValid } = useForm(); + return ( + + ); +}; +``` \ No newline at end of file diff --git a/docs/Select.mdx b/docs/Select.mdx index 0800d79..a89f7f4 100644 --- a/docs/Select.mdx +++ b/docs/Select.mdx @@ -181,7 +181,7 @@ const Submit = () => { - {asyncStatus.status === undefined && } + {asyncStatus.status === undefined && } {asyncStatus.status === "asyncStart" && } {asyncStatus.status === "asyncError" && } {asyncStatus.status === "asyncSuccess" && } diff --git a/docs/TextArea.mdx b/docs/TextArea.mdx index f0de41e..8914fed 100644 --- a/docs/TextArea.mdx +++ b/docs/TextArea.mdx @@ -139,7 +139,7 @@ const Submit = () => { return (