├── .nvmrc ├── assets ├── logo.png ├── basic_example.png ├── dynamic_form.png ├── newsletter_form.png ├── fullfeatured_form.png └── registration_form.png ├── .gitignore ├── .editorconfig ├── .eslintrc ├── babel.config.js ├── .circleci └── config.yml ├── LICENSE ├── package.json ├── test ├── utils │ └── unexpected-react.js └── Enform.test.js ├── CONTRIBUTING.md ├── src └── Enform.js ├── README.md └── docs └── index.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.15.1 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/basic_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/basic_example.png -------------------------------------------------------------------------------- /assets/dynamic_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/dynamic_form.png -------------------------------------------------------------------------------- /assets/newsletter_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/newsletter_form.png -------------------------------------------------------------------------------- /assets/fullfeatured_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/fullfeatured_form.png -------------------------------------------------------------------------------- /assets/registration_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/enform/HEAD/assets/registration_form.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | package-lock.json 5 | .npm 6 | .vscode/ 7 | /node_modules 8 | lib 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | indent_size = 2 9 | indent_style = space 10 | 11 | [*.{css,less,scss,ccss}] 12 | indent_style = space 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 10, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "plugins": [ 10 | "react-hooks" 11 | ], 12 | "rules": { 13 | "react-hooks/rules-of-hooks": "error", 14 | "react-hooks/exhaustive-deps": "warn" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | env: { 7 | test: { 8 | presets: [ 9 | [ 10 | "@babel/preset-env", 11 | { targets: { node: "current" }, modules: "commonjs" } 12 | ] 13 | ] 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10 6 | steps: 7 | - checkout # special step to check out source code to working directory 8 | 9 | - restore_cache: # special step to restore the dependency cache 10 | # Read about caching dependencies: https://circleci.com/docs/2.0/caching/ 11 | keys: 12 | - v1-repo-{{ checksum "package-lock.json" }} 13 | 14 | - run: 15 | name: Install dependencies with NPM 16 | command: yarn install # replace with `yarn install` if using yarn 17 | 18 | - save_cache: # special step to save the dependency cache 19 | key: v1-repo-{{ checksum "package-lock.json" }} 20 | paths: 21 | - "node_modules" 22 | 23 | - run: 24 | name: Run tests 25 | command: yarn test:nowatch 26 | - run: 27 | name: Linting 28 | command: yarn lint 29 | 30 | workflows: 31 | version: 2 32 | Build and Test: 33 | jobs: 34 | - build 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Miroslav Nikolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enform", 3 | "version": "2.4.1", 4 | "main": "lib/index.js", 5 | "description": "Handle React forms with joy!", 6 | "homepage": "https://github.com/moubi/enform#readme", 7 | "author": "Miroslav Nikolov", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/moubi/enform" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "form", 16 | "forms", 17 | "validation", 18 | "submit", 19 | "onchange", 20 | "state", 21 | "fields", 22 | "state-management" 23 | ], 24 | "devDependencies": { 25 | "@babel/cli": "^7.8.4", 26 | "@babel/preset-env": "^7.8.6", 27 | "@babel/preset-react": "^7.8.3", 28 | "babel-jest": "^25.1.0", 29 | "babel-minify": "^0.5.1", 30 | "eslint": "^6.8.0", 31 | "eslint-plugin-react": "^7.19.0", 32 | "eslint-plugin-react-hooks": "^4.0.2", 33 | "gzip-size-cli": "^3.0.0", 34 | "jest": "^25.1.0", 35 | "prettier": "~1.19.1", 36 | "react": "16.13.0", 37 | "react-dom": "16.13.0", 38 | "react-dom-testing": "^1.5.0", 39 | "react-test-renderer": "^16.13.0", 40 | "sinon": "9.0.0", 41 | "unexpected": "^11.0.1", 42 | "unexpected-dom": "^4.11.1", 43 | "unexpected-reaction": "^2.6.0", 44 | "unexpected-sinon": "^10.11.2" 45 | }, 46 | "peerDependencies": { 47 | "react": ">= 16.8.0" 48 | }, 49 | "scripts": { 50 | "build": "mkdir -p lib && babel ./src --out-file ./lib/index.js --no-comments --presets minify", 51 | "size": "gzip-size ./lib/index.js", 52 | "prepare": "rm -rf ./lib && npm run build", 53 | "prepublishOnly": "npm run test:nowatch", 54 | "test": "jest --watch", 55 | "test:nowatch": "jest", 56 | "lint": "eslint ." 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/utils/unexpected-react.js: -------------------------------------------------------------------------------- 1 | import unexpected from "unexpected"; 2 | import unexpectedDom from "unexpected-dom"; 3 | import unexpectedReaction from "unexpected-reaction"; 4 | import ReactDom from "react-dom"; 5 | import React, { Component } from "react"; 6 | import unexpectedSinon from "unexpected-sinon"; 7 | import { simulate } from "react-dom-testing"; 8 | import PropTypes from "prop-types"; 9 | 10 | const expect = unexpected 11 | .clone() 12 | .use(unexpectedDom) 13 | .use(unexpectedReaction) 14 | .use(unexpectedSinon); 15 | 16 | export class Mounter extends Component { 17 | render() { 18 | return
{this.props.children}
; 19 | } 20 | } 21 | 22 | Mounter.propTypes = { 23 | children: PropTypes.node 24 | }; 25 | 26 | export function getInstance(reactElement, tagName = "div") { 27 | const div = document.createElement(tagName); 28 | const element = ReactDom.render(reactElement, div); 29 | 30 | const result = { 31 | instance: element, 32 | subject: div.firstChild 33 | }; 34 | 35 | if (reactElement.type === PropUpdater) { 36 | result.applyPropsUpdate = () => 37 | simulate(result.subject.firstChild, { type: "click" }); 38 | } 39 | 40 | return result; 41 | } 42 | 43 | export class PropUpdater extends Component { 44 | constructor(props) { 45 | super(props); 46 | 47 | this.state = { 48 | isClicked: false 49 | }; 50 | } 51 | 52 | render() { 53 | const { children, propsUpdate } = this.props; 54 | const { isClicked } = this.state; 55 | 56 | let child; 57 | if (isClicked) { 58 | child = React.cloneElement(children, propsUpdate); 59 | } else { 60 | child = children; 61 | } 62 | 63 | return ( 64 |
this.setState({ isClicked: true })}>{child}
65 | ); 66 | } 67 | } 68 | 69 | PropUpdater.propTypes = { 70 | children: PropTypes.element.isRequired, 71 | propsUpdate: PropTypes.object.isRequired 72 | }; 73 | 74 | export { simulate } from "react-dom-testing"; 75 | 76 | export default expect; 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Enform 2 | 3 | ### 👏 Thank you for contributing 4 | 5 | We are all busy with jobs, family or hobbies, but also want to spend some time on open source. Time is limited resource, though, so this short guide is here to make contribution productive: 6 | 7 | ### ⌨︎ Working with the codebase 8 | Before opening a PR: 9 | - check if what you are planning to work on is in [open issues](https://github.com/moubi/enform/issues) 10 | - if not, open a new one and attach some labels *(check below)* 11 | - if it's there - join the discussion 12 | - go ahead with your PR 13 | 14 | #### 🐞 Bug report 15 | If you are about to open new bug report issue try to be more specific about things like **version, browser, OS, steps**. Don't forget to attach the bug label. 16 | 17 | Ideally, provide codesandbox example 🙏. [Fork the basic demo](https://codesandbox.io/s/basic-form-with-enform-dv69b) if that will make it easy for you. More examples to fork from are available on [docs page](docs/index.md#documentation). 18 | 19 | #### 💡 Feature request 20 | It is good to start working on new features based on issues/discussion and not opened PR in order to avoid closing irrelevant ones. Creating an issue with feature request label is a good starting point. 21 | 22 | ### ❓ If you have a question 23 | Check the [docs](docs/index.md#documentation) first. May be you will find the answer there. How about the [open issues](https://github.com/moubi/enform/issues) - did someone already ask the same? 24 | **If not feel free to create new issue and attach the question label.** 25 | 26 | ### 🏷 Issue labels/tags 27 | Mark your issues with the following lables where it make sense: 28 | - bug - for bug reports 29 | - feature request - if you want that functionality 30 | - question - want to ask about something 31 | - help wanted - if you need help with the code 32 | - discussion - anything that you would like to bring in 33 | 34 | All supported labels are [listed here](https://github.com/moubi/enform/labels). 35 | ___ 36 | 37 | ### ☕️ Thank you again for finding time for [Enform](https://github.com/moubi/enform)! 38 | -------------------------------------------------------------------------------- /src/Enform.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | // Object props does have an order 5 | const sortObj = obj => 6 | Object.keys(obj) 7 | .sort() 8 | .reduce(function(result, key) { 9 | result[key] = obj[key]; 10 | return result; 11 | }, {}); 12 | 13 | const errorsFromInitialValues = initial => 14 | Object.keys(initial).reduce( 15 | (errors, field) => ({ ...errors, [field]: false }), 16 | {} 17 | ); 18 | 19 | export const useEnform = (initial, validation) => { 20 | const [values, setValues] = useState({ ...initial }); 21 | const [errors, setErrors] = useState(() => errorsFromInitialValues(initial)); 22 | const ref = useRef(sortObj(initial)); 23 | 24 | const reset = useCallback(() => { 25 | setValues({ ...initial }); 26 | setErrors(errorsFromInitialValues(initial)); 27 | }, [initial]); 28 | 29 | useEffect(() => { 30 | // That should cover most of the use cases. 31 | // JSON.stringify is reliable here. 32 | // Enform will sort before compare stringified versions of the two object 33 | // That gives a higher chance for success without the need of deep equal. 34 | // Note: JSON.stringify doesn't handle javascript Sets and Maps. 35 | // Using such in forms is considered more of an edge case and should be 36 | // handled by consumer components by turning these into an object or array 37 | const sortedInitial = sortObj(initial); 38 | if (JSON.stringify(sortedInitial) !== JSON.stringify(ref.current)) { 39 | reset(); 40 | ref.current = sortedInitial; 41 | } 42 | }, [initial, reset]); 43 | 44 | const isDirty = useCallback(() => { 45 | const fields = Object.keys(initial); 46 | return fields.some(field => initial[field] !== values[field]); 47 | }, [initial, values]); 48 | 49 | const validate = useCallback((onValid = () => {}) => { 50 | if (!validation) return true; 51 | const newErrors = {}; 52 | 53 | for (let i in validation) { 54 | newErrors[i] = validation[i](values) || false; 55 | } 56 | 57 | const isValid = !Object.values(newErrors).some(err => err); 58 | 59 | setErrors({ 60 | ...errors, 61 | ...newErrors 62 | }); 63 | 64 | if (isValid) { 65 | onValid(values); 66 | } 67 | }, [validation, errors, values]); 68 | 69 | const validateField = useCallback(name => { 70 | if (typeof values[name] !== "undefined") { 71 | if (typeof validation[name] === "function") { 72 | const isInvalid = validation[name](values) || false; 73 | setErrors({ 74 | ...errors, 75 | [name]: isInvalid 76 | }); 77 | // Returning the oposite: is the field valid 78 | return !isInvalid; 79 | } 80 | return true; 81 | } 82 | }, [values, validation, errors]); 83 | 84 | const clearErrors = useCallback(() => { 85 | setErrors(errorsFromInitialValues(initial)); 86 | }, [initial]); 87 | 88 | const clearError = useCallback(name => { 89 | // Use an updater function here since this method is often used in 90 | // combination with onChange(). Both are setting state, so we don't 91 | // want to lose changes. 92 | setErrors(prevErrors => ({ 93 | ...prevErrors, 94 | [name]: false 95 | })); 96 | }, []); 97 | 98 | const onSubmit = useCallback(submitCallback => { 99 | validate(values => { 100 | if (typeof submitCallback === "function") { 101 | submitCallback(values); 102 | } 103 | }); 104 | }, [validate]); 105 | 106 | const onChange = useCallback((name, value) => { 107 | setValues(prevValues => ({ 108 | ...prevValues, 109 | [name]: value 110 | })); 111 | errors[name] && clearError(name); 112 | }, [errors, clearError]); 113 | 114 | // This method is usually used with API calls to programmatically set 115 | // field errors coming as a payload and not as a result of direct user input 116 | const setErrorsIfFieldsExist = useCallback(newErrors => { 117 | if (typeof newErrors !== "object") return false; 118 | const errorsCopy = { ...errors }; 119 | 120 | Object.keys(newErrors).forEach(fieldName => { 121 | if (fieldName in errorsCopy) { 122 | errorsCopy[fieldName] = newErrors[fieldName]; 123 | } 124 | }); 125 | 126 | setErrors({ ...errorsCopy }); 127 | }, [errors]); 128 | 129 | return { 130 | values, 131 | errors, 132 | onChange, 133 | onSubmit, 134 | isDirty, 135 | validateField, 136 | clearError, 137 | clearErrors, 138 | setErrors: setErrorsIfFieldsExist, 139 | reset 140 | }; 141 | }; 142 | 143 | export default function Enform({ initial, validation, children }) { 144 | return children(useEnform(initial, validation)); 145 | } 146 | 147 | Enform.propTypes = { 148 | children: PropTypes.func.isRequired, 149 | initial: PropTypes.object.isRequired, 150 | validation: PropTypes.object 151 | }; 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |

Handle React forms with joy

7 | 8 |
9 | 10 | [![moubi](https://img.shields.io/circleci/build/gh/moubi/enform?label=circleci&style=flat-square)](https://circleci.com/gh/moubi/swipeable-react) 11 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/moubi/enform.svg?style=flat-square&logo=lgtm&logoWidth=15)](https://lgtm.com/projects/g/moubi/enform/context:javascript) 12 | [![moubi](https://img.shields.io/npm/v/enform?style=flat-square)](https://www.npmjs.com/package/enform) 13 | [![moubi](https://img.shields.io/static/v1?style=flat-square&label=gzip%20size&message=1.6%20kB&color=green)](#development) 14 | [![moubi](https://img.shields.io/github/license/moubi/enform?style=flat-square)](LICENSE) 15 | 16 | [Usage](docs/index.md#basic-form-field-and-a-button) • [Examples](docs/index.md#documentation) • [API](docs/index.md#api) • [Contribute](#contributing) • [License](LICENSE) 17 |
18 | 19 | #### `` helps you manage: 20 | - form validation 21 | - form dirty state 22 | - form submission and reset 23 | - field values and changes 24 | - error messages 25 | 26 | Forms in React are common source of frustration and code repetition. Enform moves that hassle out of the way. 27 | 28 | **✔️ Check the [docs with live demos](docs/index.md#documentation) or jump to the [basic usage](#basic-usage).** 29 | 30 | ## So, another form component? 31 | Many form libraries are after wide range of use cases. As a result they grow in size bigger than the few form components one may ever need to handle. **Enform is built for most common use cases without going to the edge. Thanks to that it remains small, but very powerful.** 32 | 33 | ## Basic usage 34 | 35 | 36 | ```jsx 37 | import React from "react"; 38 | import Enform from "enform"; 39 | 40 | const App = () => ( 41 |
42 |

Simple form

43 | values.name === "" }} 46 | > 47 | {props => ( 48 |
49 | { 54 | props.onChange("name", e.target.value); 55 | }} 56 | /> 57 | 58 |
59 | )} 60 |
61 |
62 | ); 63 | ``` 64 | [![Edit Basic form with enform](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/newsletter-form-with-enform-dv69b?fontsize=14&hidenavigation=1&theme=dark) 65 | 66 | **View more intereactive [examples here](docs/index.md#documentation)**. 67 | 68 | This [⚠️ note on re-rendering](docs/index.md#%EF%B8%8F-note-on-re-rendering) gives answers to some common questions about auto updating field values based on changes in the `initial` prop. 69 | 70 | ## Install 71 | ``` 72 | yarn add enform 73 | ``` 74 | 75 | ### Requirements ✅ 76 | Enform is using React hooks ↩ as per `v2.0.0`. 77 | 78 | Consumer projects should have react >= 16.8.0 (the one with hooks) in order to use it. 79 | 80 | ## API 81 | ### Component props 82 | | Prop | Type | Required | Description | 83 | | ------------- | ------------- | -------- | ----------- | 84 | | children | function | yes | Function that your need to wrap your DOM with. It accepts the `props` object to help with form state manipulation. | 85 | | [initial](docs/index.md#initial--fieldname-value----required) | object | yes | Initial form field values in a form of `{ fieldName: value, ... }`. | 86 | | [validation](docs/index.md#validation--fieldname-functionvalues--boolstring-) | object | no | Validation for the fields. It takes the form of `{ fieldName: function(values), ... }` where `function(values)` accepts all form field values and should return an error message or truthy value. Example: `{ username: values => values.username === "" ? "This field is required" : false }`. | 87 | 88 | ✔️ Read more about these props [here](docs/index.md#enform-component-props). 89 | 90 | ### State Api 91 | Enform exposes its handy Api by passing an `object` down to the function wrapper. 92 | ```jsx 93 | 94 | {props => ( 95 |
96 | ... 97 |
98 | )} 99 |
100 | ``` 101 | **The `props` object contains 2 data items:** 102 | |prop|Description| 103 | |-|-| 104 | | [values](docs/index.md#propsvalues--fieldname-value-)               | Current field values - `{ fieldName: value, ... }`. | 105 | | [errors](docs/index.md#propserrors--fieldname-value-)               | Current field errors - `{ fieldName: errorMessage, ... }`. | 106 | 107 | **and these 8 methods:** 108 | 109 | |method|Description| 110 | |-|-| 111 | | [onChange](docs/index.md#propsonchange-fieldname-value--void) | Updates single field's value - `onChange(fieldName, value)`. The `value` is usually what what comes from `e.target.value`. **Side effects:** clears previously set field error. | 112 | | [onSubmit](docs/index.md#propsonsubmit-functionvalues--void--void) | `onSubmit(successCallback)`. Usually attached to a button click or directly to `
` onSubmit. `successCallback(values)` will only be executed if all validations pass. **Side effects:** triggers validation or calls successCallback. | 113 | | [reset](docs/index.md#propsreset---void) | Empties form elements. | 114 | | [isDirty](docs/index.md#propsisdirty---bool) | Reports if the form is dirty. It takes into account the `initial` field values passed to ``. | 115 | | [validateField](docs/index.md#propsvalidatefield-fieldname--bool)     | Triggers single form field validation - `validateField(fieldName)`. | 116 | | [clearError](docs/index.md#propsclearerror-fieldname--void) | Clears single form field's error - `clearError(fieldName)`. | 117 | | [clearErrors](docs/index.md#propsclearerrors---void) | Clears all errors in the form. | 118 | | [setErrors](docs/index.md#propssetErrors--fieldName-errorMessagebool---void) | Sets Enform's internal error state directly. This may be handy when `props.errors` needs to be updated based on an API call (async) and not on user input. Example: `setErrors({ email: "Example error message" }, ...)` | 119 | 120 | `props.values` get updated with `onChange` and `reset` calls. 121 | 122 | `props.errors` get updated with `onChange`, `onSubmit`, `reset`, `validateField`, `clearError`, `clearErrors` and `setErrors` calls. 123 | 124 | ✔️ See more details about [Enform's state API](docs/index.md#enform-state-api). 125 | 126 | ## [Documentation](docs/index.md) 127 | Docs has its own home [here](docs/index.md#documentation). It further expands on the topics covered previously. Many [examples](docs/index.md#examples) and [how to guides](docs/index.md#how-to) for variety of use cases take place on its pages too. Ref to this [⚠️ note on re-rendering](docs/index.md#%EF%B8%8F-note-on-re-rendering) for a common pitfall case. 128 | 129 | ## Development 130 | Run tests with `jest` in watch mode 131 | ``` 132 | yarn test 133 | ``` 134 | or no watch 135 | 136 | ``` 137 | yarn test:nowatch 138 | ``` 139 | Get gzip size by 140 | ``` 141 | yarn size 142 | ``` 143 | 144 | Build with 145 | ``` 146 | yarn build 147 | ``` 148 | That will pipe `src/Enform.js` through babel and put it as `index.js` under `lib/` folder. 149 | 150 | ## Contributing 151 | You are welcome to open pull requests, issues with bug reports (use [codesandbox](https://codesandbox.io/)) and suggestions or simply **tweet about Enform**. Check the relavant [guides here](CONTRIBUTING.md). 152 | 153 | **Immediate and fun contrubution:** help create more usable examples. Is it a full-fetured form, third party integration or a filter form with bunch of options - feel free fork the [basic form in codesandbox](https://codesandbox.io/s/basic-form-with-enform-dv69b). 154 | 155 | ## Inspiration 156 | Enform is inspired by my experience with form refactoring, [@jaredpalmer](https://jaredpalmer.com/)'s great work on [Formik](https://github.com/jaredpalmer/formik) and the way [@kamranahmedse](https://github.com/kamranahmedse)'s presented [driver.js](https://github.com/kamranahmedse/driver.js). 157 | 158 | ## Authors 159 | Miroslav Nikolov ([@moubi](https://github.com/moubi)) 160 | 161 | ## License 162 | [MIT](LICENSE) 163 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | - [Overview](#overview) 4 | - [Examples](#examples) 5 | - [Basic form (field and a button)](#basic-form-field-and-a-button) 6 | - [Newsletter form](#newsletter-form) 7 | - [Registration form](#registration-form) 8 | - [Form with dynamic elements](#form-with-dynamic-elements) 9 | - [Full-featured form](#full-featured-form) 10 | - [API](#api) 11 | - [Enform component props](#enform-component-props) 12 | - [initial](#initial--fieldname-value----required) 13 | - [validation](#validation--fieldname-functionvalues--boolstring-) 14 | - [Enform state API](#enform-state-api) 15 | - [props.values](#propsvalues--fieldname-value-) 16 | - [props.errors](#propserrors--fieldname-value-) 17 | - [props.onChange](#propsonchange-fieldname-value--void) 18 | - [props.onSubmit](#propsonsubmit-functionvalues--void--void) 19 | - [props.reset](#propsreset---void) 20 | - [props.isDirty](#propsisdirty---bool) 21 | - [props.validateField](#propsvalidatefield-fieldname--bool) 22 | - [props.clearError](#propsclearerror-fieldname--void) 23 | - [props.clearErrors](#propsclearerrors---void) 24 | - [props.setErrors](#propssetErrors--fieldName-errorMessagebool---void) 25 | - [How to](#how-to) 26 | - [handle validation](#handle-validation) 27 | - [reset a form](#reset-a-form) 28 | - [submit a form](#submit-a-form) 29 | - [disable button based on dirty state](#disable-button-based-on-dirty-state) 30 | - [handle contentEditable elements](#handle-contenteditable-elements) 31 | - [handle form-like DOM](#handle-form-like-dom) 32 | - [⚠️ Note on re-rendering](#%EF%B8%8F-note-on-re-rendering) 33 | 34 | ## Overview 35 | Enform was born while trying to deal with forms in React repetitive times with state involved in the picture as usual. Let's face it, things always end up the same. The result is a big state object to manage and a bunch of component methods to handle changes, submission and validation. 36 | 37 | It feels like these should be somehow hidden or extracted away in another component. `` is such a component that uses the **"render props" pattern. It nicely moves that state management away by taking advantage of React's superpower**. 38 | 39 | Ok, enough theory, let's see some real use cases. 40 | 41 | ## Examples 42 | All examples in this section are available in [Codesandbox](https://codesandbox.io/s/basic-form-with-enform-dv69b) with the latest version of Enform. Feel free to experiment, fork or share. Ping me if you think I have messed something up 🤭. 43 | 44 | ### Basic form (field and a button) 45 | 46 | 47 | ```jsx 48 | import React from "react"; 49 | import Enform from "enform"; 50 | 51 | const App = () => ( 52 |
53 |

Simple form

54 | values.name === "" }} 57 | > 58 | {props => ( 59 |
60 | { 65 | props.onChange("name", e.target.value); 66 | }} 67 | /> 68 | 69 |
70 | )} 71 |
72 |
73 | ); 74 | ``` 75 | [![Edit Basic form with enform](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/newsletter-form-with-enform-dv69b?fontsize=14&hidenavigation=1&theme=dark) 76 | 77 | **Few things to note here:** 78 | - `initial` (required) prop is set with the field's default value 79 | - `validation` object sets the field should not be empty 80 | - `props.onSubmit` is bound to the button click. It will submit whenever validation is passed 81 | - the input field is fully controlled with the help of `props.values` and `props.onChange`. 82 | 83 | This [⚠️ note on re-rendering](#%EF%B8%8F-note-on-re-rendering) may save few hours of headache too. 84 | ___ 85 | 86 | ### Newsletter form 87 | 88 | 89 | ```jsx 90 | 94 | !/^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,125}[a-z]{2,63}$/.test( 95 | values.email 96 | ) 97 | }} 98 | > 99 | {props => ( 100 |
101 | { 107 | props.onChange("email", e.target.value); 108 | }} 109 | /> 110 | 111 |
112 | )} 113 |
114 | ``` 115 | [![Edit Newsletter form with enform](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/newsletter-form-with-enform-t1zyk?fontsize=14&hidenavigation=1&theme=dark) 116 | 117 | In this example `validation` is set for the email field using RegEx. It will return `true` if email is invalid or `false` otherwise. All [validator functions](#validation--fieldname-functionvalues--boolstring-) must return truthy value in case of error. 118 | ___ 119 | 120 | ### Registration form 121 | 122 | 123 |
124 | Expand code snippet 125 | 126 | ```jsx 127 | { 138 | if (values.password.length < 6) { 139 | return "Password must be at least 6 chars in length!"; 140 | } else if (values.password !== values.repeatPassword) { 141 | return "Password doesn't match!"; 142 | } 143 | return false; 144 | }, 145 | repeatPassword: values => 146 | values.repeatPassword.length < 6 147 | ? "Password must be at least 6 chars in length!" 148 | : false 149 | }} 150 | > 151 | {props => ( 152 |
153 | // Other fields DOM here 154 |
155 | { 160 | props.onChange("password", e.target.value); 161 | if (props.errors.repeatPassword) { 162 | props.clearError("repeatPassword"); 163 | } 164 | }} 165 | /> 166 |

{props.errors.password}

167 |
168 | ... 169 |
170 | )} 171 |
172 | ``` 173 |
174 | 175 | [![Edit Registration form with enform](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/registration-form-with-enform-u6up9?fontsize=14&hidenavigation=1&theme=dark) 176 | 177 | This example is shortened, so that it's easy to focus on two interesting parts - **password validation** and **clearing errors**. Take a look at the [full demo in the codesandbox](https://codesandbox.io/s/registration-form-with-enform-u6up9?fontsize=14&hidenavigation=1&theme=dark). 178 | 179 | This registration form displays error messages as well. In order that to work each validator function must return the error string in case of error. The `password` validation depends on both password and repeatPassword values, so it will display two different error messages. 180 | 181 | **Secondly**, password's `onChange` should also clear the error for `repeatPassword`. `props.onChange("password", e.target.value)` does so for the password field, but it needs to be done for repeatPassword as well. That can be achieved by calling `props.clearError("repeatPassword")`. 182 | ___ 183 | 184 | ### Form with dynamic elements 185 | 186 | 187 |
188 | Expand code snippet 189 | 190 | ```jsx 191 | 202 | !/^[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,125}[a-zA-Z]{2,63}$/.test( 203 | values.email 204 | ), 205 | // Spread the validation object for the rest of the fields - 206 | // stored in the component's state. 207 | ...this.state.fieldsValidation 208 | }} 209 | > 210 | {props => ( 211 |
212 | {/* Map your newly added fields to render in the DOM */} 213 | {Object.keys(this.state.fields).map(field => ( 214 |
215 | { 221 | props.onChange(field, e.target.value); 222 | }} 223 | /> 224 | 225 |
226 | ))} 227 | { 233 | props.onChange("email", e.target.value); 234 | }} 235 | /> 236 | 237 | 238 |
239 | )} 240 |
241 | ``` 242 |
243 | 244 | [![Edit Dynamic form fields with enform](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/dynamic-form-fields-with-enform-bnho9?fontsize=14&hidenavigation=1&theme=dark) 245 | 246 | Enfrom does not automatically handle dynamic form elements (adding or removing felds), but it is possible to make it aware of such changes with few adjustments. The example above is a short version of the [codesandbox demo](https://codesandbox.io/s/dynamic-form-fields-with-enform-bnho9?fontsize=14&hidenavigation=1&theme=dark). 247 | 248 | **Let's start with the basics:** Enform wraps the form DOM and helps with handling its state. But it's required to make Enform aware when new DOM is added, just because it needs to be controlled being a form element. In this example **Enform is forced to reinitialize** whenever more fields are added or removed. It is done by setting the `key={fieldNames.length}` prop. 249 | 250 | Next logical step would be to **update** the `initial` and `validation` props with up to date fields data. *The data could be stored in the consumer's component state fx*. Last thing to do - render all these newly added fields. Enform will do the rest as usual. 251 | ___ 252 | 253 | ### Full-featured form 254 | 255 | 256 |
257 | Expand code snippet 258 | 259 | ```jsx 260 | 275 | !/^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,125}[A-Za-z]{2,63}$/.test( 276 | email 277 | ) 278 | ? "Enter valid email address!" 279 | : false, 280 | password: ({ password }) => 281 | password.length < 6 282 | ? "Password must be at least 6 chars in length!" 283 | : false, 284 | age: ({ age }) => (age === "" ? "Select age range" : false), 285 | bio: ({ bio }) => (bio.length > 140 ? "Try to be shorter!" : false) 286 | }} 287 | > 288 | {props => ( 289 |
290 |
291 | { 296 | props.onChange("email", e.target.value); 297 | // This will validate on every change. 298 | // The error will disappear once email is valid. 299 | if (props.errors.email) { 300 | props.validateField("email"); 301 | } 302 | }} 303 | /> 304 |

{props.errors.email}

305 |
306 |
307 | { 312 | props.onChange("password", e.target.value); 313 | }} 314 | /> 315 |

{props.errors.password}

316 |
317 |
318 | 330 |

{props.errors.age}

331 |
332 | 333 |
334 | { 339 | props.onChange("frontend", e.target.checked); 340 | }} 341 | /> 342 | 343 | { 348 | props.onChange("backend", e.target.checked); 349 | }} 350 | /> 351 | 352 | { 357 | props.onChange("fullstack", e.target.checked); 358 | }} 359 | /> 360 | 361 | { 366 | props.onChange("devops", e.target.checked); 367 | }} 368 | /> 369 | 370 |
371 | 372 |
373 | { 380 | props.onChange("gender", "male"); 381 | }} 382 | /> 383 | 384 | { 391 | props.onChange("gender", "female"); 392 | }} 393 | /> 394 | 395 |
396 |
397 | `, 799 | `` 800 | ]); 801 | }); 802 | 803 | it("should display error for bio field", () => { 804 | simulate(subject, [ 805 | { 806 | type: "change", 807 | target: "textarea", 808 | data: { 809 | target: { 810 | value: 811 | "This is a value much longer than 140 chars. This is a value much longer than 140 chars. This is a value much longer than 140 chars. This is a value much longer than 140 chars." 812 | } 813 | } 814 | }, 815 | { 816 | type: "click", 817 | target: ".submit" 818 | } 819 | ]); 820 | 821 | expect( 822 | subject, 823 | "queried for first", 824 | "textarea + .error", 825 | "to have text", 826 | "Try to be shorter (max 140)!" 827 | ); 828 | }); 829 | 830 | it("should display error when password doesn't match ", () => { 831 | simulate(subject, [ 832 | { 833 | type: "change", 834 | target: "[placeholder='Password (min 6)']", 835 | data: { 836 | target: { 837 | value: "1234567" 838 | } 839 | } 840 | }, 841 | { 842 | type: "change", 843 | target: "[placeholder='Repeat password']", 844 | data: { 845 | target: { 846 | value: "12345678" 847 | } 848 | } 849 | }, 850 | { 851 | type: "click", 852 | target: ".submit" 853 | } 854 | ]); 855 | 856 | expect( 857 | subject, 858 | "queried for first", 859 | "[placeholder='Password (min 6)'] + .error", 860 | "to have text", 861 | "Password doesn't match!" 862 | ); 863 | }); 864 | 865 | it("should clear repeatPassword error when changing password field", () => { 866 | simulate(subject, [ 867 | { 868 | type: "click", 869 | target: ".submit" 870 | }, 871 | { 872 | type: "change", 873 | target: "[placeholder='Password (min 6)']", 874 | data: { 875 | target: { 876 | value: "1" 877 | } 878 | } 879 | } 880 | ]); 881 | 882 | expect(subject, "queried for", ".error", "to satisfy", [ 883 |

Enter valid email address!

, 884 |

Please select an option!

885 | ]); 886 | }); 887 | 888 | describe("with form reset", () => { 889 | it("should disable reset button if form is NOT dirty", () => { 890 | expect( 891 | subject, 892 | "queried for first", 893 | ".reset", 894 | "to have attributes", 895 | "disabled" 896 | ); 897 | }); 898 | 899 | it("should enable reset button if form is dirty", () => { 900 | simulate(subject, { 901 | type: "change", 902 | target: "[placeholder=Email]", 903 | data: { 904 | target: { 905 | value: "invalid_email" 906 | } 907 | } 908 | }); 909 | 910 | expect( 911 | subject, 912 | "queried for first", 913 | ".reset", 914 | "not to have attributes", 915 | "disabled" 916 | ); 917 | }); 918 | 919 | it("should clear all errors on reset", () => { 920 | simulate(subject, [ 921 | { 922 | type: "change", 923 | target: "[placeholder=Email]", 924 | data: { 925 | target: { 926 | value: "invalid_email" 927 | } 928 | } 929 | }, 930 | { 931 | type: "click", 932 | target: ".submit" 933 | }, 934 | { 935 | type: "click", 936 | target: ".reset" 937 | } 938 | ]); 939 | 940 | expect(subject, "to contain no elements matching", ".error"); 941 | }); 942 | 943 | it("should clear all errors only", () => { 944 | simulate(subject, [ 945 | { 946 | type: "change", 947 | target: "[placeholder=Email]", 948 | data: { 949 | target: { 950 | value: "invalid_email" 951 | } 952 | } 953 | }, 954 | { 955 | type: "click", 956 | target: ".submit" 957 | }, 958 | { 959 | type: "click", 960 | target: ".clear-errors" 961 | } 962 | ]); 963 | 964 | expect(subject, "to contain no elements matching", ".error"); 965 | }); 966 | 967 | it("should clear all fields on reset", () => { 968 | simulate(subject, [ 969 | { 970 | type: "change", 971 | target: "[placeholder=Email]", 972 | data: { 973 | target: { 974 | value: "invalid_email" 975 | } 976 | } 977 | }, 978 | { 979 | type: "change", 980 | target: "[placeholder='Password (min 6)']", 981 | data: { 982 | target: { 983 | value: "1234567" 984 | } 985 | } 986 | }, 987 | { 988 | type: "change", 989 | target: "[placeholder='Repeat password']", 990 | data: { 991 | target: { 992 | value: "12345678" 993 | } 994 | } 995 | }, 996 | { 997 | type: "change", 998 | target: "select", 999 | data: { 1000 | target: { 1001 | value: "10-18" 1002 | } 1003 | } 1004 | }, 1005 | { 1006 | type: "change", 1007 | target: "textarea", 1008 | data: { 1009 | target: { 1010 | value: "I am a dev" 1011 | } 1012 | } 1013 | }, 1014 | { 1015 | type: "change", 1016 | target: "input[type=checkbox]", 1017 | data: { 1018 | target: { 1019 | checked: true 1020 | } 1021 | } 1022 | }, 1023 | { 1024 | type: "click", 1025 | target: ".submit" 1026 | }, 1027 | { 1028 | type: "click", 1029 | target: ".reset" 1030 | } 1031 | ]); 1032 | 1033 | expect(subject, "queried for", "[data-test-type=field]", "to satisfy", [ 1034 | {}} />, 1035 | {}} />, 1036 | {}} />, 1037 | , 1044 |