├── .babelrc ├── .circleci └── config.yml ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── CustomInput.js ├── FormState.js ├── index.js ├── index.md └── index.test.js ├── styleguide.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: node:9 6 | steps: 7 | - checkout 8 | - run: 9 | name: Install dependencies 10 | command: 'yarn' 11 | - run: 12 | name: Build the package 13 | command: 'yarn build' 14 | - run: 15 | name: Run tests 16 | command: 'yarn test' 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true, 7 | "impliedStrict": true 8 | } 9 | }, 10 | "env": { 11 | "browser": true 12 | }, 13 | "extends": [ 14 | "eslint:recommended", 15 | "react-app" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # styleguide 2 | /styleguide 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # editor 25 | 26 | *.swn 27 | *.swo 28 | *.swp 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Derek W. Stavis 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # React Vanilla Form 4 | > An unobtrusive form serializer and validator that works by following standards. 5 | 6 |
7 | 8 | Vanilla Form is a form serialization and validation component built upon 9 | standards. To obtain the serialized form data the only thing you need to 10 | do is to declare your form controls (native or custom!) following the 11 | standard input interfaces: Using `name`, `value`, `htmlFor` and `role` 12 | properties. 13 | 14 | Wire `onSubmit` prop to `Form` component to get the serialized data from 15 | the form. Pass `validations` to display and catch errors in the form. 16 | Use `onChange` (or not) to get realtime data updates. 17 | 18 | ```jsx static 19 | import Form from 'react-vanilla-form' 20 | 21 |
console.log(data)}> 22 | 25 | 26 |
27 | 28 | 31 | 32 |
33 | 34 | 37 | 38 |
39 | 40 | 41 |
42 | ``` 43 | 44 | Also, Vanilla Form is lightweight. It weighs only 2.7k gzipped. The only 45 | direct depedencies are 4 or 5 Ramda functions which you can treeshake on 46 | your bundler to slim it up (but you should consider using Ramda :smiley:). 47 | 48 | See the full documentation and live examples at 49 | http://derek.github.stavis.me/react-vanilla-form. 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-vanilla-form", 3 | "version": "0.8.0", 4 | "description": "An unobtrusive form serializer and validator that works by following standards.", 5 | "keywords": [ 6 | "react", 7 | "form", 8 | "validation", 9 | "serialization" 10 | ], 11 | "main": "dist/index.js", 12 | "repository": "https://github.com/derekstavis/react-vanilla-form.git", 13 | "author": "Derek Stavis", 14 | "license": "MIT", 15 | "private": false, 16 | "files": [ 17 | "src/index.js", 18 | "dist/index.js" 19 | ], 20 | "scripts": { 21 | "styleguide": "styleguidist server", 22 | "styleguide:build": "styleguidist build", 23 | "styleguide:deploy": "gh-pages -d styleguide", 24 | "build": "mkdir -p dist; babel src/index.js --out-file dist/index.js", 25 | "test": "jest --coverage --coverageReporters=json" 26 | }, 27 | "peerDependencies": { 28 | "prop-types": "*", 29 | "react": "*", 30 | "react-dom": "*" 31 | }, 32 | "dependencies": { 33 | "ramda": "0.25.0" 34 | }, 35 | "devDependencies": { 36 | "babel-cli": "^6.26.0", 37 | "babel-core": "^6.26.3", 38 | "babel-eslint": "^7.2.3", 39 | "babel-jest": "^23.0.1", 40 | "babel-loader": "^7.1.2", 41 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 42 | "babel-preset-env": "^1.7.0", 43 | "babel-preset-react": "^6.24.1", 44 | "css-loader": "^0.28.8", 45 | "enzyme": "^3.10.0", 46 | "enzyme-adapter-react-16": "^1.14.0", 47 | "eslint": "^4.18.1", 48 | "eslint-config-react-app": "^2.1.0", 49 | "eslint-plugin-flowtype": "^2.50.0", 50 | "eslint-plugin-import": "^2.13.0", 51 | "eslint-plugin-jsx-a11y": "^5.1.1", 52 | "eslint-plugin-react": "^7.10.0", 53 | "gh-pages": "^1.1.0", 54 | "jest": "^23.1.0", 55 | "milligram": "^1.3.0", 56 | "prop-types": "^15.6.0", 57 | "react": "^16.9.0", 58 | "react-dom": "^16.9.0", 59 | "react-styleguidist": "^6.1.0", 60 | "regenerator-runtime": "^0.11.1", 61 | "style-loader": "^0.19.1", 62 | "webpack": "^3.10.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/CustomInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Input = ({ 4 | errorMessage, 5 | name, 6 | onBlur, 7 | onChange, 8 | onFocus, 9 | title, 10 | type, 11 | value, 12 | }) => ( 13 |
14 | 15 | onChange(e.target.value), 20 | onFocus, 21 | type, 22 | value, 23 | }} 24 | /> 25 | {errorMessage} 26 |
27 | ) 28 | 29 | Input.defaultProps = { 30 | type: 'text', 31 | } 32 | 33 | module.exports = Input 34 | -------------------------------------------------------------------------------- /src/FormState.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Form from '.' 3 | 4 | class FormState extends React.Component { 5 | constructor (props) { 6 | super(props) 7 | this.state = {} 8 | } 9 | 10 | render () { 11 | return ( 12 |
13 |
22 | this.setState({ data, errors }) 23 | } 24 | onChange={(data, errors) => { 25 | if (!this.props.onChange) return 26 | this.setState({ data, errors }) 27 | }} 28 | > 29 | {this.props.children} 30 |
31 | {this.state.data && 32 |

33 |             result:
34 | {JSON.stringify(this.state.data, null, 2)} 35 |
36 | } 37 | {this.state.errors && 38 |

39 |             Errors:
40 | {JSON.stringify(this.state.errors, null, 2)} 41 |
42 | } 43 |
44 | ) 45 | } 46 | } 47 | 48 | FormState.defaultProps = { 49 | customErrorProp: undefined, 50 | data: undefined, 51 | errors: undefined, 52 | validateDataProp: undefined, 53 | validateOn: undefined, 54 | validation: undefined, 55 | keepErrorOnFocus: undefined, 56 | onSubmit: undefined, 57 | onChange: undefined, 58 | } 59 | 60 | module.exports = FormState 61 | 62 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import { 4 | func, 5 | node, 6 | object, 7 | oneOf, 8 | string, 9 | bool, 10 | } from 'prop-types' 11 | 12 | import { 13 | anyPass, 14 | complement, 15 | contains, 16 | dissocPath, 17 | dropLast, 18 | equals, 19 | is, 20 | isEmpty, 21 | isNil, 22 | lensPath, 23 | merge, 24 | keys, 25 | omit, 26 | partial, 27 | partialRight, 28 | pathSatisfies, 29 | reduce, 30 | set, 31 | view, 32 | } from 'ramda' 33 | 34 | const isErrorEmpty = anyPass([isNil, isEmpty, complement(Boolean)]) 35 | 36 | const getValue = event => { 37 | if (event.target) { 38 | return contains(event.target.value, ['on', 'off']) 39 | ? event.target.checked 40 | : event.target.value 41 | } 42 | 43 | return event 44 | } 45 | 46 | const isCheckable = element => 47 | element.props.type === 'radio' || 48 | element.props.type === 'checkbox' || 49 | typeof element.props.checked !== 'undefined' || 50 | typeof (element.type.defaultProps || {}).checked !== 'undefined' 51 | 52 | const ownProps = [ 53 | 'children', 54 | 'customErrorProp', 55 | 'data', 56 | 'onChange', 57 | 'onSubmit', 58 | 'keepErrorOnFocus', 59 | 'validateDataProp', 60 | 'validateOn', 61 | 'validation', 62 | ] 63 | 64 | const omitOwnProps = omit(ownProps) 65 | 66 | // eslint-disable-next-line react/no-deprecated 67 | export default class Form extends Component { 68 | constructor (props) { 69 | super(props) 70 | 71 | this.state = { 72 | data: props.data || {}, 73 | errors: props.errors || {}, 74 | } 75 | 76 | this.cloneTree = this.cloneTree.bind(this) 77 | this.validateTree = this.validateTree.bind(this) 78 | this.validateElement = this.validateElement.bind(this) 79 | this.notifyChangeEvent = this.notifyChangeEvent.bind(this) 80 | this.handleEvent = this.handleEvent.bind(this) 81 | this.handleSubmit = this.handleSubmit.bind(this) 82 | this.mergeErrors = this.mergeErrors.bind(this) 83 | 84 | if (props.validateDataProp) { 85 | this.state.errors = this.validateTree(this.state.errors, this) 86 | } 87 | } 88 | 89 | mergeErrors (newErrors) { 90 | if (newErrors && !equals(newErrors, this.state.errors)) { 91 | this.setState({ errors: merge(this.state.errors, newErrors) }) 92 | } 93 | } 94 | 95 | UNSAFE_componentWillReceiveProps(nextProps) { 96 | const { data, errors: nextErrors } = nextProps 97 | 98 | if (data && !equals(data, this.props.data)) { 99 | this.setState({ data }, () => { 100 | if (this.props.validateDataProp) { 101 | const errors = this.validateTree(this.state.errors, this) 102 | 103 | if (nextErrors && !equals(errors, nextErrors)) { 104 | this.setState({ errors: merge(errors, nextErrors) }) 105 | return 106 | } 107 | this.setState({ errors }) 108 | return 109 | } 110 | this.mergeErrors(nextErrors) 111 | }) 112 | return 113 | } 114 | 115 | if (!equals(this.props.errors, nextErrors)) { 116 | const propsErrorsKeys = keys(this.props.errors || {}) 117 | const cleanedErrors = omit(propsErrorsKeys, this.state.errors || {}) 118 | this.setState({ errors: merge(nextErrors, cleanedErrors) }) 119 | return; 120 | } 121 | 122 | this.mergeErrors(nextErrors) 123 | } 124 | 125 | notifyChangeEvent () { 126 | const { onChange } = this.props 127 | 128 | if (typeof onChange === 'function') { 129 | const { data, errors } = this.state 130 | onChange(data, errors) 131 | } 132 | } 133 | 134 | validateElement (path, data, errors) { 135 | const lens = lensPath(path) 136 | const validation = view(lens, this.props.validation) 137 | 138 | if (!validation) { 139 | return errors 140 | } 141 | 142 | const propsErrors = this.props.errors || {} 143 | const isPropsErrorDefined = view(lens, propsErrors) 144 | 145 | if (isPropsErrorDefined) { 146 | return errors 147 | } 148 | 149 | const value = view(lens, data) 150 | 151 | if (is(Array, validation)) { 152 | for (let validate of validation) { 153 | const err = validate(value, data) 154 | 155 | if (!isErrorEmpty(err)) { 156 | return set(lens, err, errors) 157 | } 158 | } 159 | } 160 | 161 | else if (typeof validation === 'function') { 162 | const err = validation(value, data) 163 | 164 | if (!isErrorEmpty(err)) { 165 | return set(lens, err, errors) 166 | } 167 | } 168 | 169 | const parentPath = dropLast(1, path) 170 | 171 | if (parentPath.length > 0) { 172 | if (pathSatisfies(isErrorEmpty, parentPath, errors)) { 173 | return dissocPath(parentPath, errors) 174 | } 175 | } 176 | 177 | return dissocPath(path, errors) 178 | } 179 | 180 | handleEvent (eventName, path, originalHandler, event) { 181 | if (typeof originalHandler === 'function') { 182 | originalHandler(event) 183 | } 184 | 185 | if (event && event.defaultPrevented) { 186 | return 187 | } 188 | 189 | const { validateOn } = this.props 190 | 191 | let data = this.state.data 192 | let errors = this.state.errors 193 | const lens = lensPath(path) 194 | 195 | if (eventName === 'change') { 196 | const value = getValue(event) 197 | data = set(lens, value, this.state.data) 198 | } 199 | 200 | if (eventName === validateOn) { 201 | errors = this.validateElement(path, data, errors) 202 | } 203 | 204 | if (eventName === 'removeError') { 205 | errors = dissocPath(path, this.state.errors) 206 | this.setState({ data, errors }) 207 | return 208 | } 209 | 210 | this.setState({ data, errors }, this.notifyChangeEvent) 211 | } 212 | 213 | cloneTree (element, index, parentPath = []) { 214 | if (!element || typeof element === 'string') { 215 | return element 216 | } 217 | 218 | const path = element.props.name 219 | ? [...parentPath, element.props.name] 220 | : parentPath 221 | 222 | if (element.type === 'fieldset') { 223 | return React.cloneElement(element, {}, React.Children.map( 224 | element.props.children, 225 | partialRight(this.cloneTree, [path]) 226 | )) 227 | } 228 | 229 | if (element.props.role === 'alert' && element.props.htmlFor) { 230 | const lens = lensPath([...path, element.props.htmlFor]) 231 | const errors = view(lens, this.state.errors) 232 | 233 | if (errors) { 234 | const children = is(Array, errors) ? errors[0] : errors 235 | return React.cloneElement(element, { children }) 236 | } 237 | 238 | return element 239 | } 240 | 241 | if (element.props.name) { 242 | const lens = lensPath(path) 243 | 244 | const { 245 | validateOn, 246 | customErrorProp: errorProp = 'error', 247 | keepErrorOnFocus, 248 | errors, 249 | } = this.props 250 | 251 | const propsErrors = errors || {} 252 | 253 | let props = {} 254 | 255 | props.onChange = partial( 256 | this.handleEvent, 257 | ['change', path, element.props.onChange] 258 | ) 259 | 260 | if (validateOn === 'blur') { 261 | props.onBlur = partial( 262 | this.handleEvent, 263 | [validateOn, path, element.props.onBlur] 264 | ) 265 | } 266 | 267 | if (validateOn === 'focus') { 268 | props.onFocus = partial( 269 | this.handleEvent, 270 | [validateOn, path, element.props.onFocus] 271 | ) 272 | } 273 | 274 | const skipValidation = propsErrors[element.props.name]; 275 | if (validateOn !== 'focus' && !keepErrorOnFocus && !skipValidation) { 276 | props.onFocus = partial( 277 | this.handleEvent, 278 | ['removeError', path, element.props.onFocus] 279 | ) 280 | } 281 | 282 | const value = view(lens, this.state.data) 283 | 284 | if (isCheckable(element)) { 285 | if (typeof value === 'boolean') { 286 | props.checked = value 287 | } else { 288 | props.checked = value === element.props.value 289 | } 290 | } else { 291 | props.value = value 292 | } 293 | 294 | const error = view(lens, this.state.errors) 295 | 296 | if (error) { 297 | props[errorProp] = error 298 | } 299 | 300 | return React.cloneElement(element, props) 301 | } 302 | 303 | if (element.props.children) { 304 | return React.cloneElement(element, {}, React.Children.map( 305 | element.props.children, 306 | partialRight(this.cloneTree, [path]) 307 | )) 308 | } 309 | 310 | return element 311 | } 312 | 313 | validateTree (errors = {}, element, parentPath = []) { 314 | if (!element || typeof element === 'string') { 315 | return errors 316 | } 317 | 318 | const children = React.Children.toArray(element.props.children) 319 | const path = element.props.name 320 | ? [...parentPath, element.props.name] 321 | : parentPath 322 | 323 | if (element.type !== 'select' && children.length > 0) { 324 | const childErrors = reduce( 325 | partialRight(this.validateTree, [path]), 326 | errors, 327 | children 328 | ) 329 | 330 | if (isErrorEmpty(childErrors)) { 331 | const parentPath = dropLast(1, path) 332 | 333 | if (parentPath.length === 0) { 334 | return {} 335 | } 336 | 337 | return dissocPath(parentPath, errors) 338 | } 339 | 340 | return childErrors 341 | } 342 | 343 | if (element.props.name) { 344 | return this.validateElement(path, this.state.data, errors) 345 | } 346 | 347 | return errors 348 | } 349 | 350 | handleSubmit (event) { 351 | event.preventDefault() 352 | 353 | const errors = this.validateTree(this.state.errors || {}, this) 354 | 355 | this.setState( 356 | { errors }, 357 | () => isErrorEmpty(this.state.errors) 358 | ? this.props.onSubmit(this.state.data) 359 | : this.props.onSubmit(this.state.data, this.state.errors) 360 | ) 361 | } 362 | 363 | render () { 364 | return ( 365 |
369 | {React.Children.map( 370 | this.props.children, 371 | partialRight(this.cloneTree, [this, []]) 372 | )} 373 |
374 | ) 375 | } 376 | } 377 | 378 | Form.propTypes = { 379 | /** 380 | * The children can contain any kind of component. Inputs with name 381 | * property will be tracked for changes using `onChange` callback. 382 | * Sibling labels with `role=alert` and `htmlFor` pointing to a validated 383 | * component will be used to present the error message. 384 | **/ 385 | children: node, 386 | /** 387 | * The validation object whose keys mirror form field structure. 388 | * Values of this object can be either functions or a function array. 389 | * Validation functions receives the input string and should return 390 | * a string message on error and a falsy value otherwise. 391 | **/ 392 | validation: object, // eslint-disable-line 393 | /** 394 | * The event where validation will be triggered. By default, field 395 | * validations runs on `change` event. 396 | **/ 397 | validateOn: oneOf(['change', 'blur', 'focus']), 398 | /** 399 | * The form submit callback. Receives the serialized form as an object. 400 | **/ 401 | onSubmit: func, 402 | /** 403 | * The form change callback. This callback runs on every form control's 404 | * `onChange`, right after validations. When this is defined, the form 405 | * behaves as a controlled component, and the user is responsible for 406 | * updating the form state via `data` prop. 407 | **/ 408 | /** 409 | * @callback onChange 410 | * @param {object} data 411 | * @param {object} errors 412 | **/ 413 | onChange: func, 414 | /** 415 | * The form data object whose keys mirror form field structure. 416 | * Setting this prop will set the form controls' values accordingly. 417 | * This can be used for rendering an initial state or to use the form 418 | * as a controlled component. 419 | **/ 420 | data: object, // eslint-disable-line 421 | /** 422 | * A property name to inject the error message in the named field. 423 | * This is useful for input wrappers with builtin error label, commonly 424 | * found in UI libraries. 425 | **/ 426 | customErrorProp: string, 427 | /** 428 | * Toggles if error messages should be kept after the input receives focus. 429 | * Not applicable if `validateOn` is set to `focus`. 430 | */ 431 | keepErrorOnFocus: bool, 432 | } 433 | 434 | Form.defaultProps = { 435 | children: undefined, 436 | customErrorProp: undefined, 437 | data: undefined, 438 | onChange: undefined, 439 | onSubmit: undefined, 440 | keepErrorOnFocus: false, 441 | validateDataProp: false, 442 | validateOn: 'change', 443 | validation: {}, 444 | } 445 | -------------------------------------------------------------------------------- /src/index.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Difficult to explain with words, eh? Let's see it in practice. Install 4 | `react-vanilla-form` package. The only component you need to import is 5 | `Form`, which is the default package export: 6 | 7 | 8 | ```jsx static 9 | import Form from 'react-vanilla-form' 10 | ``` 11 | 12 | ## Serialization 13 | 14 | Wire `onSubmit` prop from `Form` and store the result wherever you want 15 | in state: 16 | 17 | ```jsx static 18 | class FormState extends React.Component { 19 | constructor (props) { 20 | super(props) 21 | this.state = {} 22 | } 23 | 24 | render () { 25 | return ( 26 | 27 |
this.setState({ data })}> 28 | {this.props.children} 29 |
30 | 31 | {this.state.result && 32 |

 33 |             Result:
34 | {JSON.stringify(this.state.result, null, 2)} 35 |
36 | } 37 |
38 | ) 39 | } 40 | } 41 | ``` 42 | 43 | Then proceed to place components inside the form: 44 | 45 | ```jsx 46 | const FormState = require('./FormState.js'); 47 | 48 | 49 | 52 | 53 |
54 | 57 | 58 |
59 | 62 | 63 |
64 | 65 |
66 | ``` 67 | 68 | You can even nest objects infinite levels using the standard `fieldset` tag: 69 | 70 | ```jsx 71 | const FormState = require('./FormState.js'); 72 | 73 | 74 | 77 | 78 |
79 |
80 | Address 81 | 84 | 85 |
86 | 89 | 90 |
91 | 94 | 95 |
96 | 97 |
98 | ``` 99 | 100 | ## Use own components 101 | 102 | It's also possible to use custom input components as long as they follow the 103 | standard input interface: 104 | 105 | 106 | ```jsx static 107 | const Input = ({ name, type, onChange, title }) => ( 108 |
109 | 110 | onChange(e.target.value) }} /> 111 |
112 | ) 113 | 114 | Input.defaultProps = { 115 | type: "text", 116 | } 117 | ``` 118 | 119 | They should work as expected: 120 | 121 | ```jsx 122 | const FormState = require('./FormState.js'); 123 | const Input = require('./CustomInput.js'); 124 | 125 | 126 | 127 |
128 | Address 129 | 130 | 131 | 132 |
133 | 134 |
135 | ``` 136 | 137 | ## Validating data 138 | 139 | Validation is triggered both on the fly (as the user types) and when the 140 | form is submitted. It's achieved through `validation` prop in `Form`, which 141 | accepts an object whose keys mirror form field structure, specifying the 142 | validation function or function array. 143 | 144 | The validation function receives the input value and in case of an error it 145 | should return an string with the message to be displayed for the user, 146 | otherwise return `false` (or a falsy value). 147 | 148 | To capture error messages for an `input`, use a sibling `label` component 149 | pointing to the `label` using `htmlFor` and define the `role` as `alert`. 150 | When using a custom input, error messages will be passed through via 151 | `error` prop. For customizing error properties, see more on next 152 | sections. 153 | 154 | 155 | ```jsx 156 | const FormState = require('./FormState.js'); 157 | const Input = require('./CustomInput.js'); 158 | 159 | function required (value) { 160 | return value ? false : 'This field is required!' 161 | } 162 | 163 | function isNumber (value) { 164 | return parseInt(value) ? false : 'Should be a number' 165 | } 166 | 167 | 174 | 175 | 187 | ``` 188 | 189 | ### Custom error properties 190 | 191 | It is possible to receive the error message into the validated field via 192 | props by configuring the error prop name with `customErrorProp` prop. 193 | 194 | Let's say we want to improve the custom input component to include an 195 | `errorMessage` prop: 196 | 197 | ```jsx static 198 | const Input = ({ name, type, onChange, title, value, errorMessage }) => ( 199 |
200 | 201 | 202 | 203 |
204 | ) 205 | ``` 206 | 207 | Configure `customErrorProp="errorMessage"` on the form with the prop name: 208 | 209 | ```jsx 210 | const FormState = require('./FormState.js'); 211 | const Input = require('./CustomInput.js'); 212 | 213 | function required (value) { 214 | return value ? false : 'This field is required!' 215 | } 216 | 217 | 221 | 222 | 223 | 224 | ``` 225 | 226 | Now the custom input will receive the validation error as a prop. 227 | 228 | ### Run validations on different events 229 | 230 | By default, validations will run on `change` event, meaning that the 231 | feedback will be realtime, which sometimes is the desired behaviour, 232 | but sometimes might confuse users. For this cases, it's possible to 233 | change the event which will triggered via `validateOn` prop. The 234 | supported events are `change`, `focus`, `blur` and `submit`. Using 235 | `submit` will effectively disable realtime validation. 236 | 237 | ```jsx 238 | const FormState = require('./FormState.js'); 239 | const Input = require('./CustomInput.js'); 240 | 241 | function required (value) { 242 | return value ? false : 'This field is required!' 243 | } 244 | 245 | function isNumber (value) { 246 | return parseInt(value) ? false : 'Should be a number' 247 | } 248 | 249 | 260 | 261 |
262 | 263 | 264 |
265 | 266 |
267 | ``` 268 | 269 | ### Keep validation errors on focus 270 | 271 | By default, when an input has an error message and it's touched (focused), 272 | the error message for the touched input will be cleared. 273 | 274 | If you want to keep validations errors, specify `keepErrorOnFocus`: 275 | 276 | ```jsx 277 | const FormState = require('./FormState.js'); 278 | const Input = require('./CustomInput.js'); 279 | 280 | function required (value) { 281 | return value ? false : 'This field is required!' 282 | } 283 | 284 | function isNumber (value) { 285 | return parseInt(value) ? false : 'Should be a number' 286 | } 287 | 288 | 300 | 301 |
302 | 303 | 304 |
305 | 306 |
307 | 308 | ``` 309 | 310 | ## Setting form data 311 | 312 | It's possible to set the form data by passing an object whose keys mirror 313 | form field's structure via `data` prop. 314 | 315 | Currently the form keeps an internal state. When the `data` prop change 316 | the internal state will be synced with the prop's value. 317 | 318 | ```jsx 319 | const FormState = require('./FormState.js'); 320 | const Input = require('./CustomInput.js'); 321 | 322 | function required (value) { 323 | return value ? false : 'This field is required!' 324 | } 325 | 326 | function isNumber (value) { 327 | return parseInt(value) ? false : 'Should be a number' 328 | } 329 | 330 | 346 | 347 |
348 | 349 | 350 |
352 | 353 |
354 | ``` 355 | 356 | ## Validating `data` prop 357 | 358 | When setting form data via `data` prop, by default the data will not be 359 | validated. Sometimes there are situations where you may want to validate 360 | and display errors, e.g.: server-side rendering. To validate the 361 | data set through `data` prop, set `validateDataProp` to true: 362 | 363 | ```jsx 364 | const FormState = require('./FormState.js'); 365 | const Input = require('./CustomInput.js'); 366 | 367 | function required (value) { 368 | return value ? false : 'This field is required!' 369 | } 370 | 371 | function isNumber (value) { 372 | return parseInt(value) ? false : 'Should be a number' 373 | } 374 | 375 | 393 | 394 |
395 | 396 | 397 |
398 | 399 |
400 | ``` 401 | 402 | ## Setting errors manually 403 | 404 | It is possible to overwrite form errors using `errors` prop. This is 405 | useful for controlling your own validations on the parent component. 406 | 407 | > *Important*: in this case, you must control when the error is showed and cleared. 408 | 409 | ### Possible use cases: 410 | 411 | #### - async validations 412 | 413 | ```jsx 414 | const FormState = require('./FormState.js'); 415 | const Input = require('./CustomInput.js'); 416 | 417 | class ParentComponent extends React.Component { 418 | constructor (props) { 419 | super(props) 420 | 421 | this.state = { 422 | errors: { 423 | email: 'email is required' 424 | }, 425 | loading: false 426 | } 427 | 428 | this.inputTimeout = 0; 429 | this.onEmailChange = this.onEmailChange.bind(this); 430 | this.validateEmail = this.validateEmail.bind(this); 431 | } 432 | 433 | onEmailChange(value) { 434 | if (this.inputTimeout) clearTimeout(this.inputTimeout) 435 | 436 | this.setState({ loading: true, errors: undefined }) 437 | 438 | this.inputTimeout = setTimeout(() => { 439 | this.validateEmail(value) 440 | }, 500) 441 | } 442 | 443 | validateEmail(email) { 444 | const isValid = email === 'foo@example.com'; 445 | const errors = isValid ? undefined : { email: 'email already exists' } 446 | 447 | this.setState({ 448 | loading: false, 449 | errors 450 | }) 451 | } 452 | 453 | render() { 454 | const { errors, loading } = this.state; 455 | 456 | return ( 457 | 461 | 462 | 463 | 464 |

In this example, only foo@example.com is a valid email

465 |
466 | ) 467 | } 468 | } 469 | 470 | 471 | 472 | ``` 473 | 474 | #### - mixing custom errors with `this.props.validations` 475 | 476 | Using `errors` prop do not block you from using `validations` prop. You can mix both of them to reduce boilerplate and achieve more complex validations. 477 | 478 | > *Important*: `this.props.errors` messages have priority over `validations` ones. 479 | 480 | In the example below, we validate both email existence and its length. 481 | 482 | ```jsx 483 | const FormState = require('./FormState.js'); 484 | const Input = require('./CustomInput.js'); 485 | 486 | function required (value) { 487 | return value ? false : 'This field is required!' 488 | } 489 | 490 | function minEmailLength (value) { 491 | return value.length > 17 ? false : 'This email is too short' ; 492 | } 493 | 494 | class ParentComponent extends React.Component { 495 | constructor (props) { 496 | super(props) 497 | 498 | this.state = { 499 | errors: { 500 | email: undefined 501 | }, 502 | loading: false 503 | } 504 | 505 | this.inputTimeout = 0; 506 | this.onEmailChange = this.onEmailChange.bind(this); 507 | this.validateEmail = this.validateEmail.bind(this); 508 | } 509 | 510 | onEmailChange(value) { 511 | if (this.inputTimeout) clearTimeout(this.inputTimeout) 512 | 513 | this.setState({ loading: true, errors: undefined }) 514 | 515 | this.inputTimeout = setTimeout(() => { 516 | this.validateEmail(value) 517 | }, 500) 518 | } 519 | 520 | validateEmail(email) { 521 | const isValid = ['foo@example.com', 'foobar@example.com'].includes(email) 522 | const errors = isValid ? undefined : { email: 'This email already exists' } 523 | 524 | this.setState({ 525 | loading: false, 526 | errors 527 | }) 528 | } 529 | 530 | render() { 531 | const { errors, loading } = this.state; 532 | 533 | return ( 534 | 542 | 543 | 544 | 545 |

First, try using "foo@example.com". Then, try "foobar@example.com".

546 |

Both foo@example.com and foobar@example.com are available to use, but only the last matches our custom criteria (email.length > 17 )

547 |
548 | ) 549 | } 550 | } 551 | 552 | 553 | 554 | ``` 555 | 556 | ## Getting form data realtime 557 | 558 | It's possible to get the form data updates realtime using `onChange` prop. 559 | This can be useful if you want to render components conditionally based 560 | on form state. 561 | 562 | > *Important:* It's not required to retro-feed `data` prop. 563 | 564 | ```jsx 565 | const FormState = require('./FormState.js'); 566 | const Input = require('./CustomInput.js'); 567 | 568 | function required (value) { 569 | return value ? false : 'This field is required!' 570 | } 571 | 572 | function isNumber (value) { 573 | return parseInt(value) ? false : 'Should be a number' 574 | } 575 | 576 | setState({ data })} 578 | validateDataProp 579 | customErrorProp="errorMessage" 580 | data={{ 581 | name: 'Obi Wan Kenobi', 582 | address: { 583 | street: 'A galaxy far far away', 584 | number: "xxx", 585 | state: "ny", 586 | }, 587 | accepted: false, 588 | }} 589 | validation={{ 590 | name: required, 591 | address: { 592 | street: required, 593 | number: [required, isNumber], 594 | } 595 | }} 596 | > 597 | 598 | 601 | Married 602 | Single 603 |
604 | 605 | 606 | 609 | 613 |
614 | 618 |
619 | 620 |
621 | ``` 622 | 623 | ## Continue using `onChange` 624 | 625 | You can continue using `onChange` for receiving change events on specific 626 | inputs. This may be necessary if you want to check when the user has 627 | changed a single input value without needing to subscribe to form's 628 | `onChange` (and thus having to keep comparing previous/next values): 629 | 630 | ```jsx 631 | const FormState = require('./FormState.js'); 632 | const Input = require('./CustomInput.js'); 633 | 634 | 635 | 636 | 637 | 638 | ``` 639 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { configure, mount } from 'enzyme' 3 | import Adapter from 'enzyme-adapter-react-16' 4 | 5 | import { 6 | assocPath, 7 | dissocPath, 8 | merge, 9 | } from 'ramda' 10 | 11 | import Form from '.' 12 | 13 | configure({ adapter: new Adapter() }) 14 | 15 | const required = value => !value && 'required' 16 | const isNumber = value => !parseInt(value, 10) && 'isNumber' 17 | const isTrue = value => !value && 'isTrue' 18 | 19 | const trigger = (wrapper, event, query, value) => { 20 | const mockEvent = value !== undefined 21 | ? { target: { value } } 22 | : undefined 23 | 24 | wrapper.find(query) 25 | .simulate(event, mockEvent) 26 | } 27 | 28 | const change = (wrapper, query, value) => 29 | trigger(wrapper, 'change', query, value) 30 | 31 | const blur = (wrapper, query) => 32 | trigger(wrapper, 'blur', query) 33 | 34 | const focus = (wrapper, query) => 35 | trigger(wrapper, 'focus', query) 36 | 37 | const submit = (wrapper) => { 38 | wrapper.find('form').simulate('submit') 39 | } 40 | 41 | const getProp = (prop, wrapper, query) => { 42 | return wrapper.find(query).prop(prop) 43 | } 44 | 45 | const renderBaseInputs = () => ( 46 | 47 | 48 | 49 | 50 | 51 |
52 | Address 53 |
54 | 55 | 56 | 57 | 58 |
59 |
60 | 61 | 62 | 63 | 67 |
68 | 69 |