diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml new file mode 100644 index 0000000..20fea47 --- /dev/null +++ b/.github/workflows/github-actions.yml @@ -0,0 +1,64 @@ +name: Usetheform CI + +on: [push, pull_request] + +jobs: + + tests: + name: Unit Tests + Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout ๐Ÿ›Ž๏ธ + uses: actions/checkout@v3 + + - name: Run tests ๐Ÿงช + uses: actions/setup-node@v3 + with: + node-version: '14.x' + - run: npm ci + - run: npm run test:cov + + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout ๐Ÿ›Ž๏ธ + uses: actions/checkout@v3 + + - name: Build ๐Ÿ”จ + uses: actions/setup-node@v3 + with: + node-version: '14.x' + - run: npm ci + - run: npm run build --if-present + + deploy_docs: + name: Deploy Docs + runs-on: ubuntu-latest + needs: [tests, build] + if: github.ref == 'refs/heads/master' + + steps: + - name: Checkout ๐Ÿ›Ž๏ธ + uses: actions/checkout@v3 + + - name: Build Docs ๐Ÿ”จ + uses: actions/setup-node@v3 + with: + node-version: '14.x' + - run: npm ci + - run: npm run docz:build --if-present + - run: rm .gitignore + + - name: Deploy ๐Ÿš€ + uses: JamesIves/github-pages-deploy-action@v4.4.0 + with: + folder: .docz/dist # The folder the action should deploy. + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 21fb218..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: node_js -node_js: - - "10.16.3" -branches: - only: - - master -cache: npm -script: - - echo 'Build docs!' - - npm run docz:build - - rm .gitignore -deploy: - provider: pages - skip_cleanup: true - verbose: true - local_dir: .docz/dist - github_token: $GITHUB_TOKEN - on: - branch: master -after_success: -- npm run coveralls diff --git a/README.md b/README.md index 5a694c6..7141371 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Welcome! ๐Ÿ‘‹ Usetheform is a React library for composing declarative forms and - [Documentation](https://iusehooks.github.io/usetheform/) - [Features](#fire-features) - [Quickstart](#zap-quickstart) +- [Recipes](#book-recipes) - [Motivation](#motivation) - [Code Sandboxe Examples](#code-sandboxes) - [How to Contribute](#how-to-contribute) @@ -71,6 +72,64 @@ export default function App() { ); } ``` +## :book: Recipes + +### Need to read or manipulate Form's Fields anywhere outside Form context? + +#### :see_no_evil: First: create a form store + +```javascript +import { createFormStore } from 'usetheform'; + +const [formStore, useFormSelector] = createFormStore({ counter: 0 }); + +export const awesomeFormStore = formStore; +export const useAwesomeFormSelector = useFormSelector; +``` + +#### :hear_no_evil: Next: create your awesome Form + +```javascript +import { Form } from 'usetheform'; +import { awesomeFormStore } from './awesomeFormStore'; + +export default function AwesomeForm() { + return ( + <> +
+ +
+ + + ); +} +``` + +#### :speak_no_evil: Finally: bind your components, and that's it! + +Use the `useAwesomeFormSelector` hook anywhere, no providers needed. Select your state and the component will re-render on changes. + +```javascript +import { useAwesomeFormSelector } from './awesomeFormStore' + +export const Counter = () => { + const [counter, setCounterValue] = useAwesomeFormSelector((state) => state.counter); + return ( +
+ {counter} + + + +
+ ); +} +``` ## Motivation diff --git a/__tests__/api/createFormStore.spec.js b/__tests__/api/createFormStore.spec.js new file mode 100644 index 0000000..d35efe0 --- /dev/null +++ b/__tests__/api/createFormStore.spec.js @@ -0,0 +1,582 @@ +/* eslint-disable no-unused-vars */ +import React from "react"; +import { fireEvent, cleanup, act, render } from "@testing-library/react"; + +import { Form, Input, Collection, createFormStore } from "../../src"; +import Submit from "./../helpers/components/Submit"; +import Reset from "./../helpers/components/Reset"; + +afterEach(cleanup); + +describe("API => createFormStore", () => { + let myFormStore; + let Counter; + beforeEach(() => { + myFormStore = createFormStore(); + const [, useSelectorForm] = myFormStore; + Counter = ({ selector, initialFormState }) => { + const [counter, setCounter] = useSelectorForm(selector, initialFormState); + return ( +
+ {counter} + +
+ ); + }; + }); + + it("should update the counter value and reset it", () => { + const [formStore] = myFormStore; + + const { getByTestId } = render( +
+
+ +
+ state.counter} /> +
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe("0"); + + const counterInput = getByTestId("counterInput"); + const newCounterValue = "50"; + act(() => { + fireEvent.change(counterInput, { target: { value: newCounterValue } }); + }); + + expect(counterValue.textContent).toBe(newCounterValue); + + const counterResetBtn = getByTestId("counterResetBtn"); + act(() => { + fireEvent.click(counterResetBtn); + }); + + expect(counterValue.textContent).toBe("0"); + }); + + it("should update the counter value when Form is resetted", () => { + const [formStore] = myFormStore; + const initValue = "1"; + + const { getByTestId } = render( +
+
+ + + + state.counter} /> +
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe(initValue); + + const counterInput = getByTestId("counterInput"); + const newCounterValue = "50"; + act(() => { + fireEvent.change(counterInput, { target: { value: newCounterValue } }); + }); + + expect(counterValue.textContent).toBe(newCounterValue); + + const resetBtn = getByTestId("reset"); + act(() => { + fireEvent.click(resetBtn); + }); + + expect(counterValue.textContent).toBe(initValue); + }); + + it("should update the counter value and validate the form", () => { + const [formStore] = myFormStore; + + const initValue = "3"; + const { getByTestId } = render( +
+
+ (val && Number(val) >= 3 ? undefined : "error") + ]} + name="counter" + data-testid="counterInput" + /> + + + state.counter} /> +
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe(initValue); + + const counterResetBtn = getByTestId("counterResetBtn"); + act(() => { + fireEvent.click(counterResetBtn); + }); + + expect(counterValue.textContent).toBe("0"); + + const submit = getByTestId("submit"); + expect(submit.disabled).toBe(true); + }); + + it("should initialize the counter value with a default", () => { + const [formStore] = myFormStore; + const initialState = { counter: "1" }; + + const { getByTestId } = render( +
+
+ +
+ state.counter} + initialFormState={initialState} + /> +
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe("1"); + }); + + it("should update the counter value and reset it within an object Collection", () => { + const [formStore] = myFormStore; + + const { getByTestId } = render( +
+ state.test.counter} /> +
+ + + +
+
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe("0"); + + const counterInput = getByTestId("counterInput"); + const newCounterValue = "50"; + act(() => { + fireEvent.change(counterInput, { target: { value: newCounterValue } }); + }); + + expect(counterValue.textContent).toBe(newCounterValue); + + const counterResetBtn = getByTestId("counterResetBtn"); + act(() => { + fireEvent.click(counterResetBtn); + }); + + expect(counterValue.textContent).toBe("0"); + }); + + it("should update the counter value and reset it within an array Collection", () => { + const [formStore] = myFormStore; + + const { getByTestId } = render( +
+ state.test[0]} /> +
+ + + +
+
+ ); + + const counterValue = getByTestId("counterValue"); + expect(counterValue.textContent).toBe("0"); + + const counterInput = getByTestId("counterInput"); + const newCounterValue = "50"; + act(() => { + fireEvent.change(counterInput, { target: { value: newCounterValue } }); + }); + + expect(counterValue.textContent).toBe(newCounterValue); + + const counterResetBtn = getByTestId("counterResetBtn"); + act(() => { + fireEvent.click(counterResetBtn); + }); + + expect(counterValue.textContent).toBe("0"); + }); + + it("should update the Form", () => { + const [formStore, useSelectorForm] = myFormStore; + + const FormDriver = () => { + const [formValue, setFormValue] = useSelectorForm(state => state); + return ( +
+ {JSON.stringify(formValue)} + +
+ ); + }; + + const initialState = { user: { name: "micky" } }; + const { getByTestId } = render( +
+ +
+ + + +
+
+ ); + + const formVal = getByTestId("formVal"); + expect(formVal.textContent).toBe( + JSON.stringify({ user: { name: "micky" } }) + ); + + const userInput = getByTestId("userInput"); + const newInputValue = "Antonio"; + act(() => { + fireEvent.change(userInput, { target: { value: newInputValue } }); + }); + + expect(formVal.textContent).toBe( + JSON.stringify({ user: { name: newInputValue } }) + ); + + const formResetBtn = getByTestId("formResetBtn"); + act(() => { + fireEvent.click(formResetBtn); + }); + + expect(formVal.textContent).toBe(JSON.stringify({ user: { name: "foo" } })); + }); + + it("should update an object Collection", () => { + const [formStore, useSelectorForm] = myFormStore; + + const ObjectCollection = () => { + const [collectionVal, setCollectionVal] = useSelectorForm( + state => state.user + ); + return ( +
+ + {JSON.stringify(collectionVal)} + + +
+ ); + }; + + const initialState = { user: { name: "micky" } }; + const { getByTestId } = render( +
+ +
+ + + +
+
+ ); + + const collectionVal = getByTestId("collectionVal"); + expect(collectionVal.textContent).toBe(JSON.stringify({ name: "micky" })); + + const userInput = getByTestId("userInput"); + const newValue = "Antonio"; + act(() => { + fireEvent.change(userInput, { target: { value: newValue } }); + }); + + expect(collectionVal.textContent).toBe(JSON.stringify({ name: newValue })); + + const collectionResetBtn = getByTestId("collectionResetBtn"); + act(() => { + fireEvent.click(collectionResetBtn); + }); + + expect(collectionVal.textContent).toBe(JSON.stringify({ name: "foo" })); + }); + + it("should update a nested object Collection", () => { + const [formStore, useSelectorForm] = myFormStore; + + const ObjectCollection = () => { + const [collectionVal, setCollectionVal] = useSelectorForm( + state => state.colors.palette + ); + return ( +
+ + {JSON.stringify(collectionVal)} + + +
+ ); + }; + + const initialState = { colors: { palette: { current: "red" } } }; + const { getByTestId } = render( +
+
+ + + + + +
+ +
+ ); + + const collectionResetBtn = getByTestId("collectionResetBtn"); + act(() => { + fireEvent.click(collectionResetBtn); + }); + + const collectionVal = getByTestId("collectionVal"); + expect(collectionVal.textContent).toBe(JSON.stringify({ current: "blue" })); + + const userInput = getByTestId("userInput"); + const newCounterValue = "yellow"; + act(() => { + fireEvent.change(userInput, { target: { value: newCounterValue } }); + }); + + expect(collectionVal.textContent).toBe( + JSON.stringify({ current: newCounterValue }) + ); + }); + + it("should update an array Collection", () => { + const [formStore, useSelectorForm] = myFormStore; + + const ArrayCollection = () => { + const [collectionVal, setCollectionVal] = useSelectorForm( + state => state.values + ); + return ( +
+ + {JSON.stringify(collectionVal)} + + +
+ ); + }; + + const initialState = { values: ["0"] }; + const { getByTestId } = render( +
+ +
+ + + +
+
+ ); + + const collectionVal = getByTestId("collectionVal"); + expect(collectionVal.textContent).toBe(JSON.stringify(["0"])); + + const userInput = getByTestId("userInput"); + const newValue = "1"; + act(() => { + fireEvent.change(userInput, { target: { value: newValue } }); + }); + + expect(collectionVal.textContent).toBe(JSON.stringify([newValue])); + + const collectionResetBtn = getByTestId("collectionResetBtn"); + act(() => { + fireEvent.click(collectionResetBtn); + }); + + expect(collectionVal.textContent).toBe(JSON.stringify(["0"])); + }); + + it("should update an object Collection with reducers", () => { + const [formStore, useSelectorForm] = myFormStore; + + const ObjectCollection = () => { + const [collectionVal, setCollectionVal] = useSelectorForm( + state => state.user + ); + return ( +
+ + {JSON.stringify(collectionVal)} + + +
+ ); + }; + + const fullNameFN = nextValue => { + const fullName = [nextValue["name"], nextValue["lastname"]] + .filter(Boolean) + .join(" "); + const newValue = { ...nextValue, fullName }; + return newValue; + }; + const initialState = { user: { name: "micky", lastname: "test" } }; + const { getByTestId } = render( +
+ +
+ + + + + +
+
+ ); + + const collectionVal = getByTestId("collectionVal"); + expect(collectionVal.textContent).toBe( + JSON.stringify({ + name: "micky", + lastname: "test", + fullName: "micky test" + }) + ); + + const userInput = getByTestId("userInput"); + const newValue = "Antonio"; + act(() => { + fireEvent.change(userInput, { target: { value: newValue } }); + }); + + expect(collectionVal.textContent).toBe( + JSON.stringify({ + name: newValue, + lastname: "test", + fullName: `${newValue} test` + }) + ); + + const collectionResetBtn = getByTestId("collectionResetBtn"); + act(() => { + fireEvent.click(collectionResetBtn); + }); + + expect(collectionVal.textContent).toBe( + JSON.stringify({ + name: "foo", + lastname: "test", + fullName: `foo test` + }) + ); + }); + + it("should throw if selector is not a function", () => { + const originalError = console.error; + console.error = jest.fn(); + + const [formStore] = myFormStore; + + expect(() => + render( +
+
+ +
+ +
+ ) + ).toThrow( + "createFormStore: the state selector argument must be a function" + ); + + console.error = originalError; + }); + + it("should throw if the initial form state is not an object", () => { + const originalError = console.error; + console.error = jest.fn(); + + const [formStore] = myFormStore; + + expect(() => + render( +
+
+ +
+ state.counter} initialFormState={[]} /> +
+ ) + ).toThrow( + "createFormStore: the initial form state argument must be an object" + ); + + console.error = originalError; + }); +}); diff --git a/docs/Collection.mdx b/docs/Collection.mdx index 67058ab..f04d058 100644 --- a/docs/Collection.mdx +++ b/docs/Collection.mdx @@ -136,7 +136,7 @@ import { Form, Input, Collection } from "usetheform";
Array Collection of Input fields with indexes handled automatically for custom Inputs. -```javascript +```jsx import React from "react"; import { withIndex, useField, Collection } from "usetheform"; @@ -232,7 +232,7 @@ Validation at Collection level starts only on form submission if the prop **`tou Async Validation for Collections is triggered on Submit event. The form submission is prevented if the validation fails. This means that the onSubmit function passed as prop to the **Form** component will not be invoked. -```javascript +```jsx import { useAsyncValidation, useForm } from 'usetheform' const Submit = () => { diff --git a/docs/Form.mdx b/docs/Form.mdx index b8cf9a0..4d52664 100644 --- a/docs/Form.mdx +++ b/docs/Form.mdx @@ -171,7 +171,7 @@ import { reduceTotalPrice,reduceTotalQuantity } from './components/Item/utils'; #### Detailed Explanation: -```javascript +```jsx export const Item = ({ price, qty, desc }) => { return ( @@ -262,7 +262,7 @@ import { Form, Collection, Input, useAsyncValidation } from 'usetheform'; #### Detailed Explanation: -```javascript +```jsx import { useForm } from 'usetheform' const Submit = () => { diff --git a/docs/FormContext.mdx b/docs/FormContext.mdx index 59e4b6d..8c9aad5 100644 --- a/docs/FormContext.mdx +++ b/docs/FormContext.mdx @@ -223,7 +223,7 @@ import { FormContext, Collection, Input, useAsyncValidation } from 'usetheform'; #### Detailed Explanation: -```javascript +```jsx import { useForm } from 'usetheform' const Submit = () => { diff --git a/docs/Input.mdx b/docs/Input.mdx index 7431e08..01c8751 100644 --- a/docs/Input.mdx +++ b/docs/Input.mdx @@ -55,7 +55,7 @@ Renders all the inputs of the types listed at: [W3schools Input Types](https://w - When you need to access the underlying DOM node created by an Input (e.g. to call focus), you can use a ref to store a reference to the input dom node. -```javascript +```jsx const ref = useRef(null) ``` @@ -148,7 +148,7 @@ import { Form, Input } from 'usetheform'; #### Detailed Explanation: -```javascript +```jsx import { useForm } from 'usetheform' export const asyncTestInput = value => diff --git a/docs/Select.mdx b/docs/Select.mdx index a8c4d4e..1e65e36 100644 --- a/docs/Select.mdx +++ b/docs/Select.mdx @@ -51,7 +51,7 @@ It accepts, as props, any html attribute listed at: [Html Select Attributes](htt - When you need to access the underlying DOM node created by Select (e.g. to call focus), you can use a ref to store a reference to the select dom node. -```javascript +```jsx const ref = useRef(null) ``` @@ -145,7 +145,7 @@ const ref = useRef(null) ## Validation - Async -```javascript +```jsx import { useAsyncValidation, useForm } from 'usetheform' const Submit = () => { diff --git a/docs/TextArea.mdx b/docs/TextArea.mdx index 75ebcbc..a4d4314 100644 --- a/docs/TextArea.mdx +++ b/docs/TextArea.mdx @@ -46,7 +46,7 @@ Renders a *textarea* element ([W3schools Textarea](https://www.w3schools.com/tag - When you need to access the underlying DOM node created by TextArea (e.g. to call focus), you can use a ref to store a reference to the textarea dom node. -```javascript +```jsx const ref = useRef(null)