├── .eslintignore ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── demo-app ├── .env ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── ReactoFormExample.js │ ├── ReactoFormExampleMUI.js │ ├── ReactoFormHookExample.js │ ├── ReactoFormHookExampleMUI.js │ ├── ReactoFormHookExampleUpdateMUI.js │ ├── formValidator.js │ ├── index.css │ ├── index.js │ └── serviceWorker.js ├── jestSetup.js ├── lib ├── Form.js ├── Form.test.js ├── FormList.js ├── FormList.test.js ├── __snapshots__ │ ├── Form.test.js.snap │ └── FormList.test.js.snap ├── index.js ├── muiCheckboxOptions.js ├── muiOptions.js ├── shared │ ├── bracketsToDots.js │ ├── bracketsToDots.test.js │ ├── filterErrorsForNames.js │ ├── getDateFromDateTimeValues.js │ ├── getDateTimeValuesFromDate.js │ ├── propTypes.js │ └── recursivelyCloneElements.js └── useReactoForm.js ├── package-lock.json └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | cjs 2 | esm 3 | node_modules 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | cjs 3 | esm 4 | .DS_Store 5 | node_modules 6 | npm-debug.log 7 | .idea 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # reacto-form CHANGELOG 2 | 3 | ## 1.5.1 4 | 5 | Update `@babel/runtime` and `lodash` dependencies to their latest versions 6 | 7 | ## 1.5.0 8 | 9 | Add support for MUI `Checkbox` along with examples of it in demo app. 10 | 11 | ## 1.4.2 12 | 13 | Fix form `value` prop data getting lost after a successful submission. 14 | 15 | ## 1.4.1 16 | 17 | Upgrade transitive dependencies to fix vulnerabilities 18 | 19 | ## 1.4.0 20 | 21 | Both `form.submit()` and `form.validate()` now reliably return a Promise that resolves with the updated errors array. This allows you to await form submission and easily check the errors after. 22 | 23 | ## 1.3.0 24 | 25 | - Update `Form` component to work with MUI in a way similar to `useReactoForm` 26 | - Allow settings keys to `false` in `propNames` to omit those input props 27 | 28 | ## 1.2.0 29 | 30 | - The `getInputProps` function returned by `useReactoForm` hook now returns a `hasBeenValidated` boolean prop. 31 | - `useReactoForm` hook now includes `resetValue` in returned object. 32 | 33 | ## 1.1.0 34 | 35 | Introduce React Hook: `useReactoForm` 36 | 37 | ## 1.0.0 38 | 39 | Various non-breaking changes 40 | 41 | ## 0.0.1 42 | 43 | Initial release 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 Eric Dobbertin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reacto-form 2 | 3 | This package is a very lightweight implementation of a form handler that works with React input based on the [Composable Form Specification](https://composableforms.netlify.app/user/). ReactoForm works best with inputs that fully implement this specification, but it can be adjusted to work with most React form input components that are at least similar to the specification. 4 | 5 | This package exports the following things that help you quickly combine, validate, and submit form data collected by React input components: 6 | 7 | - `useReactoForm` React hook (preferred) 8 | - `Form` React component (in case you're stuck with a class component) 9 | 10 | Additionally, it exports a `FormList` React component that is an example of building a dynamic array from form inputs. 11 | 12 | Another package, [reacto-form-inputs](https://github.com/longshotlabs/reacto-form-inputs), provides examples of various types of inputs that conform to the spec. In general they are robust, tested, and production ready, but you may want to copy and modify them to style them to your needs. Alternatively, ReactoForm can be made to work with many popular React UI frameworks, and this is most likely what you want to do with this package. 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm i reacto-form 18 | ``` 19 | 20 | ## Importing 21 | 22 | ### Recommended 23 | 24 | Import CommonJS from `reacto-form/cjs/`. Example, assuming you have Babel configured to convert all `import` to `require`: 25 | 26 | ```js 27 | import Form from "reacto-form/cjs/Form"; 28 | import FormList from "reacto-form/cjs/FormList"; 29 | import useReactoForm from "reacto-form/cjs/useReactoForm"; 30 | ``` 31 | 32 | Import ECMAScript module from `reacto-form/esm/`. Example: 33 | 34 | ```js 35 | import Form from "reacto-form/esm/Form"; 36 | import FormList from "reacto-form/esm/FormList"; 37 | import useReactoForm from "reacto-form/esm/useReactoForm"; 38 | ``` 39 | 40 | ### Alternative 41 | 42 | You can also use named imports from the package entry point, but this may result in a larger bundle size versus importing directly from the component path. 43 | 44 | ```js 45 | import { Form, FormList, useReactoForm } from "reacto-form"; 46 | ``` 47 | 48 | ## Example 49 | 50 | See https://github.com/longshotlabs/reacto-form-inputs#example 51 | 52 | ## Demo App 53 | 54 | ```bash 55 | cd demo-app 56 | npm start 57 | ``` 58 | 59 | ## useReactoForm Hook 60 | 61 | _Available since v1.2.0_ 62 | 63 | The newest and best way to use ReactoForm is with the aptly named `useReactoForm` React hook. Unless your form is in a class component, where React hooks don't work, you should always use this hook. For class components, use the `Form` component described below. 64 | 65 | In a nutshell, you call the hook in your component function, passing options, and then use the returned functions to inject the proper form logic into all of your input components as standard props. 66 | 67 | Here's the simplest possible example, using [SimpleSchema](https://github.com/aldeed/simple-schema-js) to create the validator function. You could choose to write your own validation function or use any validation package you like, with a small wrapper to adjust the errors structure if necessary. 68 | 69 | ```js 70 | import React from "react"; 71 | import Button from "@material-ui/core/Button"; 72 | import { ErrorsBlock, Field, Input } from "reacto-form-inputs"; 73 | import useReactoForm from "reacto-form/esm/useReactoForm"; 74 | import SimpleSchema from "simpl-schema"; 75 | 76 | const formSchema = new SimpleSchema({ 77 | firstName: { 78 | type: String, 79 | min: 4, 80 | }, 81 | lastName: { 82 | type: String, 83 | min: 2, 84 | }, 85 | }); 86 | 87 | const validator = formSchema.getFormValidator(); 88 | 89 | export default function ReactoFormHookExample() { 90 | // Here we call the hook function. None of the options are required, but in general 91 | // you would always want a `validator` function and an `onSubmit` function. 92 | const { getErrors, getInputProps, submitForm } = useReactoForm({ 93 | onChange: (formData) => { 94 | console.log("onChangeForm", formData); 95 | }, 96 | onChanging: (formData) => { 97 | console.log("onChangingForm", formData); 98 | }, 99 | onSubmit: (formData) => { 100 | console.log("onSubmitForm", formData); 101 | }, 102 | validator, 103 | // value - optionally pass an object representing the current form data, if it's an update form or has default values 104 | }); 105 | 106 | return ( 107 | /* Note that we need not wrap our fields in
, or really in anything */ 108 |
109 | /* We can use `getErrors` to get all of the errors related to one or more 110 | fields, based on the field path */ 111 | 116 | /* We can use `getInputProps` to get all props for a single field path 117 | */ 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | /* The submit action must call the `submitForm` function that `useReactoForm` 126 | returned */ 127 | 128 |
129 | ); 130 | } 131 | ``` 132 | 133 | Here's a full list of what you can pass to `useReactoForm`: 134 | 135 | - `hasBeenValidated`: Pass a boolean to override the internal tracking of whether the `validator` function has been called since the form was created or reset. 136 | - `isReadOnly`. Pass a boolean or a function that accepts the current form data object as its only argument and returns a boolean. If `true`, all inputs controlled by the form will be in read-only mode (disabled). ReactoForm also automatically makes all of the inputs read only while the form is being submitted. 137 | - `logErrorsOnSubmit`: Pass `true` to log all errors in the console when `submitForm` is called, if there are any errors. This can be helpful during initial development and when debugging in case you have forgotten to show any errors in the UI. 138 | - `onChange`: This function will be called with the new form data object whenever any input changes 139 | - `onChanging`: This function will be called with the new form data object whenever any input is in the process of changing (for example, while a slider is moving but not yet released, while a finger is moving but not yet lifted, while a user is typing but hasn't yet tabbed to the next field). 140 | - `onSubmit`: This function will be called with the form data object when you call `submitForm`, if the form is valid or `shouldSubmitWhenInvalid` is `true`. 141 | - `revalidateOn`: Set this to "changing", "changed", or "submit". The default is "changing". This determines how often `validator` will be called (thus reactively updating `errors`) when `hasBeenValidated` is `true`. When `hasBeenValidated` is `false`, then the `validateOn` setting is used. 142 | - Note that these are additive; "changing" causes validation before `onChanging` is called, before `onChange` is called, AND before `onSubmit` is called; "changed" causes validation before `onChange` is called AND before `onSubmit` is called; "submit" causes validation only before `onSubmit` is called. 143 | - If you don't need validation, simply don't pass a `validator` function. 144 | - `shouldSubmitWhenInvalid`: Normally `onSubmit` will not be called if `validator` returns any errors. To override this and call `onSubmit` anyway, set this option to `true`. The second argument passed to `onSubmit` will be an `isValid` boolean. 145 | - `validateOn`: Set this to "changing", "changed", or "submit". The default is "submit". This determines how often `validator` will be called (thus reactively updating `errors`) when `hasBeenValidated` is `false`. When `hasBeenValidated` is `true`, then the `revalidateOn` setting is used. 146 | - Note that these are additive; "changing" causes validation before `onChanging` is called, before `onChange` is called, AND before `onSubmit` is called; "changed" causes validation before `onChange` is called AND before `onSubmit` is called; "submit" causes validation only before `onSubmit` is called. 147 | - If you don't need validation, simply don't pass a `validator` function. 148 | - `validator`: This is the validation function. Use any validation library you want as long as you return an errors array with [this structure](https://composableforms.netlify.app/spec/errors/#errors), or a Promise that resolves with such an array. 149 | - `value`: The current form data. Pass this for an update form or to provide default values for some of the inputs. 150 | 151 | Here's a full list of what you can get from the object returned by `useReactoForm`: 152 | 153 | - `getInputProps`: A function that returns a props object that conforms to the [Composable Form Input Specification](https://composableforms.netlify.app/user/input/). Pass a unique field path string as the first argument. For example, `getInputProps("email")` will return input props that result in the form data object `{ email: "" }` while `getInputProps("address.city")` will return input props that result in the form data object `{ address: { city: "" } }`. If you are using a compliant input component, simply pass the returned props to that input and everything will be wired up for you. If you are using a non-compliant input component, you may still be able to make it work. See the Material UI example below. 154 | - `formData`: The current form data object. This initially matches the `value` you provide but changes as the user fills out the form. If you call `resetValue`, this will once again match the `value` you provide. 155 | - `getErrors`: A function that returns an errors array like this: https://composableforms.netlify.app/spec/errors/#errors. The signature is `(fieldPaths, { includeDescendantErrors = false } = {})`. `fieldPaths` is an array of object paths. `includeDescendantErrors` would for example include an error for `"address.city"` when `fieldPaths` is `["address"]`. 156 | - `getFirstError`: A function similar to `getErrors` but returns only the first error matching any field path, or `null` if there are none. The signature is `(fieldPaths, { includeDescendantErrors = false } = {})`. 157 | - `getFirstErrorMessage`: A function similar to `getFirstError` but returns only the first error message string matching any field path, or `null` if there are none. The signature is `(fieldPaths, { includeDescendantErrors = false } = {})`. 158 | - `hasBeenValidated`: Boolean indicating whether `validator` has been called since the form was created or since `resetValue` was last called. 159 | - `hasErrors`: A function similar to `getErrors` but returns only `true` if there are any errors or `false` if not. The signature is `(fieldPaths, { includeDescendantErrors = false } = {})`. 160 | - `isDirty`: This will be `true` if the form data state has changed from the initial form `value` (i.e. if the user has changed any inputs). 161 | - `resetValue`: Call this function to reset `formData` to `value`, thus causing `isDirty` to be `false`. 162 | - `submitForm`: Call this function to validate and submit all inputs (i.e., to call `validator` followed by `onSubmit`). 163 | 164 | ### useReactoForm Hook with non-compliant inputs (Material UI example) 165 | 166 | Material UI is a great framework, but unfortunately the React input components do not currently match the [Composable Form Input Specification](https://composableforms.netlify.app/user/input/) in several ways. For example, the [TextField](https://material-ui.com/api/text-field/) has the following differences: 167 | 168 | - It complains when you pass `null` as `value`, and it considers the input to be "uncontrolled" when you pass `undefined` as `value`. Instead, it expects an empty string. 169 | - `onChange` is called while changing, `onBlur` is called after the change, and `onChanging` is never called and causes a console warning. 170 | - `isReadOnly` prop is named `disabled` 171 | 172 | Fortunately, the `useReactoForm` `getInputProps` function takes some options which allow us to change the names of the returned props, omit returned props, and convert `null` value to some other value: 173 | 174 | ```js 175 | getInputProps("email", { 176 | nullValue: "", 177 | onChangeGetValue: (event) => event.target.value, 178 | onChangingGetValue: (event) => event.target.value, 179 | propNames: { 180 | errors: false, 181 | hasBeenValidated: false, 182 | isReadOnly: "disabled", 183 | onChange: "onBlur", 184 | onChanging: "onChange", 185 | onSubmit: false, 186 | }, 187 | }); 188 | ``` 189 | 190 | To simplify this further, this package exports these options as `muiOptions`: 191 | 192 | ```js 193 | import muiOptions from "reacto-form/esm/muiOptions"; 194 | 195 | getInputProps("email", muiOptions); 196 | ``` 197 | 198 | Similarly, you can import `muiCheckboxOptions` for an MUI `Checkbox` component: 199 | 200 | ```js 201 | import muiOptions from "reacto-form/esm/muiCheckboxOptions"; 202 | 203 | getInputProps("isMarried", muiCheckboxOptions); 204 | ``` 205 | 206 | Here's a full example: 207 | 208 | ```js 209 | import React from "react"; 210 | import Button from "@material-ui/core/Button"; 211 | import Checkbox from "@material-ui/core/Checkbox"; 212 | import FormControlLabel from "@material-ui/core/FormControlLabel"; 213 | import FormGroup from "@material-ui/core/FormGroup"; 214 | import TextField from "@material-ui/core/TextField"; 215 | import muiCheckboxOptions from "reacto-form/esm/muiCheckboxOptions"; 216 | import muiOptions from "reacto-form/esm/muiOptions"; 217 | import useReactoForm from "reacto-form/esm/useReactoForm"; 218 | import SimpleSchema from "simpl-schema"; 219 | 220 | const formSchema = new SimpleSchema({ 221 | firstName: { 222 | type: String, 223 | min: 4, 224 | }, 225 | lastName: { 226 | type: String, 227 | min: 2, 228 | }, 229 | isMarried: { 230 | type: Boolean, 231 | optional: true, 232 | }, 233 | }); 234 | 235 | const onSubmit = (formData) => { 236 | console.log("onSubmitForm", formData); 237 | }; 238 | const validator = formSchema.getFormValidator(); 239 | 240 | export default function ReactoFormHookExampleMUI() { 241 | const { 242 | getFirstErrorMessage, 243 | getInputProps, 244 | hasErrors, 245 | submitForm, 246 | } = useReactoForm({ 247 | onSubmit, 248 | validator, 249 | }); 250 | 251 | return ( 252 |
253 | 260 | 267 | 268 | } 270 | label="Are you married?" 271 | {...getInputProps("isMarried", muiCheckboxOptions)} 272 | /> 273 | 274 | 275 |
276 | ); 277 | } 278 | ``` 279 | 280 | ## Form Component 281 | 282 | Implements the [Form spec](spec/form.md). 283 | 284 | In addition to following the spec, these props are supported: 285 | 286 | - Use `style` or `className` props to help style the HTML form container, which is a DIV rather than a FORM. 287 | - Set `logErrorsOnSubmit` to `true` to log validation errors to the console when submitting. This can help you figure out why your form isn't submitting if, for example, you forgot to include an ErrorsBlock somewhere so there is an error not shown to the user. 288 | 289 | [Usage](https://composableforms.netlify.app/user/form/) 290 | 291 | ### Using Form with non-compliant inputs (Material UI example) 292 | 293 | _Works in 1.3.0+_ 294 | 295 | Material UI is a great framework, but unfortunately the React input components do not currently match the [Composable Form Input Specification](https://composableforms.netlify.app/user/input/) in several ways. For example, the [TextField](https://material-ui.com/api/text-field/) has the following differences: 296 | 297 | - It complains when you pass `null` as `value`, and it considers the input to be "uncontrolled" when you pass `undefined` as `value`. Instead, it expects an empty string. 298 | - `onChange` is called while changing, `onBlur` is called after the change, and `onChanging` is never called and causes a console warning. 299 | - `isReadOnly` prop is named `readOnly` 300 | 301 | Fortunately, the `Form` component takes some options in the `inputOptions` props which allow us to change the names of the returned props, omit returned props, and convert `null` value to some other value: 302 | 303 | ```js 304 | const inputOptions = { 305 | nullValue: "", 306 | propNames: { 307 | errors: false, 308 | hasBeenValidated: false, 309 | isReadOnly: "readOnly", 310 | onChange: "onBlur", 311 | onChanging: "onChange", 312 | onSubmit: false, 313 | }, 314 | }; 315 | 316 | /* MUI inputs */
; 317 | ``` 318 | 319 | To simplify this further, this package exports these options as `muiOptions`: 320 | 321 | ```js 322 | import muiOptions from "reacto-form/esm/muiOptions"; 323 | 324 |
/* MUI inputs */
; 325 | ``` 326 | 327 | Here's a full example: 328 | 329 | ```js 330 | import React, { useRef } from "react"; 331 | import Button from "@material-ui/core/Button"; 332 | import TextField from "@material-ui/core/TextField"; 333 | import Form from "reacto-form/esm/Form"; 334 | import muiOptions from "reacto-form/esm/muiOptions"; 335 | import SimpleSchema from "simpl-schema"; 336 | 337 | const formSchema = new SimpleSchema({ 338 | firstName: { 339 | type: String, 340 | min: 4, 341 | }, 342 | lastName: { 343 | type: String, 344 | min: 2, 345 | }, 346 | }); 347 | 348 | const onSubmit = (formData) => { 349 | console.log("onSubmitForm", formData); 350 | }; 351 | const validator = formSchema.getFormValidator(); 352 | 353 | export default function ReactoFormExampleMUI() { 354 | const formRef = useRef(null); 355 | 356 | return ( 357 |
358 |
364 | 374 | 384 | 387 | 388 |
389 | ); 390 | } 391 | ``` 392 | 393 | ## FormList Component 394 | 395 | Implements the [FormList spec](spec/list.md). 396 | 397 | This implementation appears as a list with the item template on the right and remove buttons on the left, plus a final row with an add button in it. 398 | 399 | In addition to following the spec, you can use the following props to help style the component: 400 | 401 | - addButtonText: String to use as the text of the add button. Default "+" 402 | - addItemRowStyle: Style object for the row after the last item, where the add button is 403 | - buttonClassName: String of space-delimited classes to use on the add and remove buttons 404 | - buttonStyle: Style object for the add and remove buttons 405 | - className: String of space-delimited classes to use on the list container 406 | - itemAreaClassName: String of space-delimited classes to use on the inner container of each item 407 | - itemAreaStyle: Style object for the inner container of each item 408 | - itemClassName: String of space-delimited classes to use on the outer container of each item 409 | - itemStyle: Style object for the outer container of each item 410 | - itemRemoveAreaClassName: String of space-delimited classes to use on the remove button area of each item 411 | - itemRemoveAreaStyle: Style object for the remove button area of each item 412 | - removeButtonText: String to use as the text of the remove buttons. Default "–" 413 | - style: Style object for the list container 414 | 415 | If you want a different add/remove experience that can't be achieved with classes or styles, then you'll need to make your own implementation of FormList. 416 | 417 | [Usage](https://composableforms.netlify.app/user/list/) 418 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // See https://babeljs.io/docs/en/config-files#root-babelconfigjs-files 2 | module.exports = function getBabelConfig(api) { 3 | const isTest = api.env("test"); 4 | 5 | // Config for when running Jest tests 6 | if (isTest) { 7 | return { 8 | plugins: ["@babel/plugin-proposal-class-properties"], 9 | presets: ["@babel/env", "@babel/preset-react"], 10 | }; 11 | } 12 | 13 | // We set this in the `build:modules` package.json script 14 | const esmodules = process.env.BABEL_MODULES === "1"; 15 | 16 | const babelEnvOptions = { 17 | modules: esmodules ? false : "auto", 18 | // https://babeljs.io/docs/en/babel-preset-env#targets 19 | targets: { 20 | // 'browsers' target is ignored when 'esmodules' is true 21 | esmodules, 22 | }, 23 | }; 24 | 25 | return { 26 | ignore: ["**/*.test.js", "__snapshots__"], 27 | plugins: [ 28 | "@babel/plugin-proposal-class-properties", 29 | [ 30 | "@babel/plugin-transform-runtime", 31 | { 32 | useESModules: esmodules, 33 | }, 34 | ], 35 | ], 36 | presets: [["@babel/env", babelEnvOptions], "@babel/preset-react"], 37 | sourceMaps: true, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /demo-app/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /demo-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /demo-app/README.md: -------------------------------------------------------------------------------- 1 | A small app demoing ReactoForm for example code and testing purposes. Run it with `npm start`. 2 | -------------------------------------------------------------------------------- /demo-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.2", 7 | "react": "^16.8.6", 8 | "react-dom": "^16.8.6", 9 | "react-scripts": "^5.0.1", 10 | "reacto-form": "^1.5.0", 11 | "reacto-form-inputs": "^1.1.0", 12 | "simpl-schema": "^1.10.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longshotlabs/reacto-form/b8fc0cb24b934aa44602f983c8a36e283ddf5958/demo-app/public/favicon.ico -------------------------------------------------------------------------------- /demo-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /demo-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo-app/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo-app/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Paper from '@material-ui/core/Paper'; 3 | import Tabs from '@material-ui/core/Tabs'; 4 | import Tab from '@material-ui/core/Tab'; 5 | import ReactoFormExample from "./ReactoFormExample"; 6 | import ReactoFormExampleMUI from "./ReactoFormExampleMUI"; 7 | import ReactoFormHookExample from "./ReactoFormHookExample"; 8 | import ReactoFormHookExampleMUI from "./ReactoFormHookExampleMUI"; 9 | import ReactoFormHookExampleUpdateMUI from "./ReactoFormHookExampleUpdateMUI"; 10 | import './App.css'; 11 | 12 | function App() { 13 | const [currentTab, setCurrentTab] = useState(0); 14 | const [updateFormData, setUpdateFormData] = useState({ 15 | firstName: "Existing", 16 | lastName: "Name", 17 | isMarried: true 18 | }); 19 | 20 | return ( 21 | 22 | setCurrentTab(newValue)} 26 | textColor="primary" 27 | value={currentTab} 28 | style={{ 29 | borderBottomColor: "#cccccc", 30 | borderBottomStyle: "solid", 31 | borderBottomWidth: 1 32 | }} 33 | > 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | {currentTab === 0 && } 42 | {currentTab === 1 && } 43 | {currentTab === 2 && } 44 | {currentTab === 3 && } 45 | {currentTab === 4 && } 46 |
47 |
48 | ); 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /demo-app/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /demo-app/src/ReactoFormExample.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import { Form } from "reacto-form"; 5 | import { ErrorsBlock, Field, Input } from "reacto-form-inputs"; 6 | import validator from "./formValidator"; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | button: { 10 | marginTop: theme.spacing(1), 11 | }, 12 | errors: { 13 | color: theme.palette.error.main 14 | }, 15 | field: { 16 | marginBottom: theme.spacing(2), 17 | marginTop: theme.spacing(2), 18 | }, 19 | input: { 20 | display: "block", 21 | lineHeight: 2, 22 | marginTop: theme.spacing(0.5), 23 | width: "100%" 24 | }, 25 | root: { 26 | marginLeft: "auto", 27 | marginRight: "auto", 28 | width: "50%", 29 | } 30 | })); 31 | 32 | async function mySubmissionFunction(...args) { 33 | console.log("Submit", ...args); 34 | } 35 | 36 | export default function ReactoFormExample() { 37 | const classes = useStyles(); 38 | const formRef = useRef(null); 39 | 40 | return ( 41 |
42 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /demo-app/src/ReactoFormExampleMUI.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import Form from "reacto-form/esm/Form"; 6 | import muiOptions from "reacto-form/esm/muiOptions"; 7 | import validator from "./formValidator"; 8 | 9 | TextField.isFormInput = true; 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | button: { 13 | marginTop: theme.spacing(1), 14 | }, 15 | errors: { 16 | color: theme.palette.error.main 17 | }, 18 | field: { 19 | marginBottom: theme.spacing(2), 20 | marginTop: theme.spacing(2), 21 | }, 22 | input: { 23 | display: "block", 24 | lineHeight: 2, 25 | marginTop: theme.spacing(0.5), 26 | width: "100%" 27 | }, 28 | root: { 29 | marginLeft: "auto", 30 | marginRight: "auto", 31 | width: "50%", 32 | } 33 | })); 34 | 35 | export default function ReactoFormExample() { 36 | const classes = useStyles(); 37 | const formRef = useRef(null); 38 | 39 | return ( 40 |
41 |
{ console.log("onChange", formData); }} 45 | onChanging={(formData) => { console.log("onChanging", formData); }} 46 | onSubmit={(formData) => { console.log("onSubmit", formData); }} 47 | ref={formRef} 48 | validator={validator} 49 | > 50 | { 57 | if (event.key === "Enter") formRef.current && formRef.current.submit(); 58 | }} 59 | /> 60 | { 67 | if (event.key === "Enter") formRef.current && formRef.current.submit(); 68 | }} 69 | /> 70 | 78 | 79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /demo-app/src/ReactoFormHookExample.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import { ErrorsBlock, Field, Input } from "reacto-form-inputs"; 5 | import useReactoForm from "reacto-form/esm/useReactoForm"; 6 | import validator from "./formValidator"; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | button: { 10 | marginTop: theme.spacing(1), 11 | }, 12 | errors: { 13 | color: theme.palette.error.main 14 | }, 15 | field: { 16 | marginBottom: theme.spacing(2), 17 | marginTop: theme.spacing(2), 18 | }, 19 | input: { 20 | display: "block", 21 | lineHeight: 2, 22 | marginTop: theme.spacing(0.5), 23 | width: "100%" 24 | }, 25 | root: { 26 | marginLeft: "auto", 27 | marginRight: "auto", 28 | width: "50%", 29 | } 30 | })); 31 | 32 | async function mySubmissionFunction(...args) { 33 | console.log("Submit", ...args); 34 | } 35 | 36 | export default function ReactoFormHookExample() { 37 | const classes = useStyles(); 38 | 39 | const { 40 | getErrors, 41 | getInputProps, 42 | submitForm 43 | } = useReactoForm({ 44 | logErrorsOnSubmit: true, 45 | onChange: (val) => { console.log("onChangeForm", val); }, 46 | onChanging: (val) => { console.log("onChangingForm", val); }, 47 | onSubmit: mySubmissionFunction, 48 | validator, 49 | }); 50 | 51 | return ( 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /demo-app/src/ReactoFormHookExampleMUI.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import Checkbox from '@material-ui/core/Checkbox'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import FormGroup from '@material-ui/core/FormGroup'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import muiOptions from "reacto-form/esm/muiOptions"; 9 | import muiCheckboxOptions from "reacto-form/esm/muiCheckboxOptions"; 10 | import useReactoForm from "reacto-form/esm/useReactoForm"; 11 | import validator from "./formValidator"; 12 | 13 | const useStyles = makeStyles(theme => ({ 14 | button: { 15 | marginTop: theme.spacing(1), 16 | }, 17 | root: { 18 | marginLeft: "auto", 19 | marginRight: "auto", 20 | width: "50%", 21 | } 22 | })); 23 | 24 | async function mySubmissionFunction(...args) { 25 | console.log("Submit", ...args); 26 | } 27 | 28 | export default function ReactoFormHookExampleMUI() { 29 | const classes = useStyles(); 30 | 31 | const { 32 | getFirstErrorMessage, 33 | getInputProps, 34 | hasErrors, 35 | submitForm 36 | } = useReactoForm({ 37 | logErrorsOnSubmit: true, 38 | onChange: (val) => { console.log("onChangeForm", val); }, 39 | onChanging: (val) => { console.log("onChangingForm", val); }, 40 | onSubmit: mySubmissionFunction, 41 | validator, 42 | isReadOnly: true 43 | }); 44 | 45 | return ( 46 |
47 | { 53 | if (event.key === "Enter") submitForm(); 54 | }} 55 | {...getInputProps("firstName", muiOptions)} 56 | /> 57 | { 63 | if (event.key === "Enter") submitForm(); 64 | }} 65 | {...getInputProps("lastName", muiOptions)} 66 | /> 67 | 68 | 71 | } 72 | label="Are you married?" 73 | {...getInputProps("isMarried", muiCheckboxOptions)} 74 | /> 75 | 76 | 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /demo-app/src/ReactoFormHookExampleUpdateMUI.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import Checkbox from '@material-ui/core/Checkbox'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import FormGroup from '@material-ui/core/FormGroup'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import muiOptions from "reacto-form/esm/muiOptions"; 9 | import muiCheckboxOptions from "reacto-form/esm/muiCheckboxOptions"; 10 | import useReactoForm from "reacto-form/esm/useReactoForm"; 11 | import validator from "./formValidator"; 12 | 13 | const useStyles = makeStyles(theme => ({ 14 | button: { 15 | marginTop: theme.spacing(1), 16 | }, 17 | root: { 18 | marginLeft: "auto", 19 | marginRight: "auto", 20 | width: "50%", 21 | } 22 | })); 23 | 24 | export default function ReactoFormHookExampleUpdateMUI(props) { 25 | const { 26 | setUpdateFormData, 27 | updateFormData 28 | } = props; 29 | 30 | const classes = useStyles(); 31 | 32 | const { 33 | getFirstErrorMessage, 34 | getInputProps, 35 | hasErrors, 36 | submitForm 37 | } = useReactoForm({ 38 | logErrorsOnSubmit: true, 39 | onChange: (val) => { console.log("onChangeForm", val); }, 40 | onChanging: (val) => { console.log("onChangingForm", val); }, 41 | onSubmit(formData) { 42 | setUpdateFormData(formData); 43 | }, 44 | validator, 45 | value: updateFormData, 46 | }); 47 | 48 | return ( 49 |
50 | { 56 | if (event.key === "Enter") submitForm(); 57 | }} 58 | {...getInputProps("firstName", muiOptions)} 59 | /> 60 | { 66 | if (event.key === "Enter") submitForm(); 67 | }} 68 | {...getInputProps("lastName", muiOptions)} 69 | /> 70 | 71 | 74 | } 75 | label="Are you married?" 76 | {...getInputProps("isMarried", muiCheckboxOptions)} 77 | /> 78 | 79 | 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /demo-app/src/formValidator.js: -------------------------------------------------------------------------------- 1 | import SimpleSchema from "simpl-schema"; 2 | 3 | const formSchema = new SimpleSchema({ 4 | firstName: { 5 | type: String, 6 | min: 4 7 | }, 8 | lastName: { 9 | type: String, 10 | min: 2 11 | }, 12 | isMarried: { 13 | type: Boolean, 14 | optional: true 15 | } 16 | }); 17 | 18 | const validator = formSchema.getFormValidator(); 19 | 20 | export default validator; 21 | -------------------------------------------------------------------------------- /demo-app/src/index.css: -------------------------------------------------------------------------------- 1 | html, body, div#root { height: 100%; } 2 | 3 | div#root { 4 | background-color: #444444; 5 | padding-top: 20px; 6 | padding-bottom: 20px; 7 | } 8 | 9 | body { 10 | margin: 0; 11 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 12 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 13 | sans-serif; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | code { 19 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 20 | monospace; 21 | } 22 | -------------------------------------------------------------------------------- /demo-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /demo-app/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /jestSetup.js: -------------------------------------------------------------------------------- 1 | // This file is loaded from the jest.setupFiles config in package.json 2 | 3 | import "core-js/stable"; // eslint-disable-line import/no-extraneous-dependencies 4 | import Enzyme from "enzyme"; // eslint-disable-line import/no-extraneous-dependencies 5 | import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; // eslint-disable-line import/no-extraneous-dependencies 6 | 7 | Enzyme.configure({ adapter: new Adapter() }); 8 | -------------------------------------------------------------------------------- /lib/Form.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import isEqual from "lodash/isEqual"; 4 | import get from "lodash/get"; 5 | import set from "lodash/set"; 6 | import unset from "lodash/unset"; 7 | import clone from "clone"; 8 | 9 | import bracketsToDots from "./shared/bracketsToDots"; 10 | import customPropTypes from "./shared/propTypes"; 11 | import filterErrorsForNames from "./shared/filterErrorsForNames"; 12 | import recursivelyCloneElements from "./shared/recursivelyCloneElements"; 13 | 14 | // To ensure we do not mutate objects passed in, we'll do a deep clone. 15 | function cloneValue(value) { 16 | return value ? clone(value) : {}; 17 | } 18 | 19 | class Form extends Component { 20 | static isForm = true; 21 | 22 | constructor(props) { 23 | super(props); 24 | 25 | this.state = { 26 | errors: [], 27 | hasBeenValidated: false, 28 | value: cloneValue(props.value), 29 | }; 30 | 31 | this.elementRefs = []; 32 | } 33 | 34 | componentDidMount() { 35 | this._isMounted = true; 36 | } 37 | 38 | // eslint-disable-next-line camelcase 39 | UNSAFE_componentWillReceiveProps(nextProps) { 40 | const { hasBeenValidated, value } = this.props; 41 | const { 42 | hasBeenValidated: hasBeenValidatedNext, 43 | value: nextValue, 44 | } = nextProps; 45 | 46 | // Whenever a changed value prop comes in, we reset state to that, thus becoming clean. 47 | if (!isEqual(value, nextValue)) { 48 | this.setState({ errors: [], value: cloneValue(nextValue) }); 49 | } 50 | 51 | // Let props override the `hasBeenValidated` state 52 | if ( 53 | typeof hasBeenValidatedNext === "boolean" && 54 | hasBeenValidatedNext !== hasBeenValidated 55 | ) { 56 | this.setState({ hasBeenValidated: hasBeenValidatedNext }); 57 | } 58 | } 59 | 60 | componentWillUnmount() { 61 | this._isMounted = false; 62 | } 63 | 64 | getFieldOnSubmitHandler(fieldHandler) { 65 | return () => { 66 | if (fieldHandler) fieldHandler(); 67 | this.submit(); 68 | }; 69 | } 70 | 71 | getFieldOnChangeHandler(fieldName, fieldHandler) { 72 | return (value) => { 73 | if (fieldHandler) fieldHandler(value); 74 | 75 | const { validateOn, revalidateOn } = this.props; 76 | const { errors, hasBeenValidated } = this.state; 77 | 78 | this.doSet(this.state.value, fieldName, value); 79 | 80 | if ( 81 | validateOn === "changed" || 82 | validateOn === "changing" || 83 | (hasBeenValidated && 84 | (revalidateOn === "changed" || revalidateOn === "changing")) 85 | ) { 86 | this.validate().then((updatedErrors) => { 87 | if (!this._isMounted) return null; 88 | this.props.onChange(this.state.value, updatedErrors.length === 0); 89 | }); 90 | } else { 91 | this.props.onChange(this.state.value, errors.length === 0); 92 | } 93 | }; 94 | } 95 | 96 | getFieldOnChangingHandler(fieldName, fieldHandler) { 97 | return (value) => { 98 | if (fieldHandler) fieldHandler(value); 99 | 100 | const { validateOn, revalidateOn } = this.props; 101 | const { errors, hasBeenValidated } = this.state; 102 | 103 | this.doSet(this.state.value, fieldName, value); 104 | 105 | if ( 106 | validateOn === "changing" || 107 | (hasBeenValidated && revalidateOn === "changing") 108 | ) { 109 | this.validate().then((updatedErrors) => { 110 | if (!this._isMounted) return null; 111 | this.props.onChanging(this.state.value, updatedErrors.length === 0); 112 | }); 113 | } else { 114 | this.props.onChanging(this.state.value, errors.length === 0); 115 | } 116 | }; 117 | } 118 | 119 | getValue() { 120 | return this.state.value; 121 | } 122 | 123 | getErrors(fieldPaths, { includeDescendantErrors = false } = {}) { 124 | const { errors } = this.props; 125 | 126 | if (!Array.isArray(fieldPaths)) { 127 | throw new Error( 128 | "First argument to getErrors must be an array of field paths" 129 | ); 130 | } 131 | 132 | return filterErrorsForNames(errors, fieldPaths, !includeDescendantErrors); 133 | } 134 | 135 | getFirstError(fieldPaths, options) { 136 | const fieldErrors = this.getErrors(fieldPaths, options); 137 | if (fieldErrors.length === 0) return null; 138 | return fieldErrors[0]; 139 | } 140 | 141 | getFirstErrorMessage(fieldPaths, options) { 142 | const fieldError = this.getFirstError(fieldPaths, options); 143 | return (fieldError && fieldError.message) || null; 144 | } 145 | 146 | resetValue() { 147 | this.setState( 148 | { 149 | errors: [], 150 | hasBeenValidated: false, 151 | value: cloneValue(this.props.value), 152 | }, 153 | () => { 154 | this.elementRefs.forEach((element) => { 155 | if (element && typeof element.resetValue === "function") 156 | element.resetValue(); 157 | }); 158 | } 159 | ); 160 | } 161 | 162 | doSet(obj, path, value, callback) { 163 | // Since we clone the object whenever we set state from props, we can directly 164 | // set the prop rather than copying the whole object. 165 | if (value === undefined) { 166 | unset(obj, path); 167 | } else { 168 | set(obj, path, value); 169 | } 170 | this.setState({ value: obj }, callback); 171 | } 172 | 173 | // Form is dirty if value prop doesn't match value state. Whenever a changed 174 | // value prop comes in, we reset state to that, thus becoming clean. 175 | isDirty() { 176 | return !isEqual(this.state.value, this.props.value); 177 | } 178 | 179 | hasErrors(fieldPaths, options) { 180 | return this.getErrors(fieldPaths, options).length > 0; 181 | } 182 | 183 | /** 184 | * @return {Promise} A Promise that resolves with an array of errors. If the 185 | * array is empty, there were no errors and submission was successful. 186 | */ 187 | submit() { 188 | const { logErrorsOnSubmit, onSubmit, shouldSubmitWhenInvalid } = this.props; 189 | const { value } = this.state; 190 | return this.validate() 191 | .then((errors) => { 192 | if (logErrorsOnSubmit && errors.length > 0) console.error(errors); 193 | 194 | if (!this._isMounted) return errors; 195 | if (errors.length && !shouldSubmitWhenInvalid) return errors; 196 | 197 | return Promise.resolve() 198 | .then(() => { 199 | // onSubmit should ideally return a Promise so that we can wait 200 | // for submission to complete, but we won't worry about it if it doesn't 201 | return onSubmit(value, errors.length === 0); 202 | }) 203 | .then(({ ok = true, errors: submissionErrors = [] } = {}) => { 204 | // Submission result must be an object with `ok` bool prop 205 | // and optional submission errors 206 | if (!Array.isArray(submissionErrors)) { 207 | throw new Error( 208 | "onSubmit returned an errors value that is not an array" 209 | ); 210 | } 211 | 212 | if (this._isMounted) { 213 | if (ok) { 214 | this.resetValue(); 215 | } else { 216 | this.setState({ errors: submissionErrors }); 217 | } 218 | } 219 | 220 | return submissionErrors; 221 | }) 222 | .catch((error) => { 223 | if (error) console.error('Form "onSubmit" function error:', error); 224 | }); 225 | }) 226 | .catch((error) => { 227 | if (error) console.error('Form "validate" function error:', error); 228 | }); 229 | } 230 | 231 | validate() { 232 | const { validator } = this.props; 233 | const { value } = this.state; 234 | 235 | if (typeof validator !== "function") return Promise.resolve([]); 236 | 237 | return validator(value).then((errors) => { 238 | if (!Array.isArray(errors)) { 239 | console.error( 240 | "validator function must return a Promise that resolves with an array" 241 | ); 242 | return []; 243 | } 244 | 245 | if (this._isMounted) { 246 | this.setState({ errors, hasBeenValidated: true }); 247 | } 248 | 249 | return errors; 250 | }); 251 | } 252 | 253 | renderFormFields() { 254 | let { value } = this.state; 255 | if (!value) value = {}; 256 | 257 | const { 258 | children, 259 | inputOptions: { nullValue, propNames }, 260 | } = this.props; 261 | 262 | let { errors: propErrors } = this.props; 263 | const { errors: stateErrors, hasBeenValidated } = this.state; 264 | if (!Array.isArray(propErrors)) propErrors = []; 265 | const errors = propErrors.concat(stateErrors); 266 | 267 | this.elementRefs = []; 268 | 269 | const propsFunc = (element) => { 270 | const newProps = {}; 271 | 272 | if (element.type.isFormField) { 273 | const name = element.props[propNames.name]; 274 | if (!name) return {}; 275 | 276 | if (element.props.errors === undefined) { 277 | newProps.errors = filterErrorsForNames(errors, [name], false); 278 | } 279 | } else if (element.type.isFormErrors) { 280 | const { names } = element.props; 281 | if (!names) return {}; 282 | if (element.props[propNames.errors] === undefined) { 283 | newProps[propNames.errors] = filterErrorsForNames( 284 | errors, 285 | names, 286 | true 287 | ); 288 | } 289 | } else if ( 290 | element.type.isFormInput || 291 | element.type.isForm || 292 | element.type.isFormList 293 | ) { 294 | const name = element.props[propNames.name]; 295 | if (!name) return {}; 296 | 297 | newProps[propNames.onChange] = this.getFieldOnChangeHandler( 298 | name, 299 | element.props[propNames.onChange] 300 | ); 301 | newProps[propNames.onChanging] = this.getFieldOnChangingHandler( 302 | name, 303 | element.props[propNames.onChanging] 304 | ); 305 | newProps[propNames.onSubmit] = this.getFieldOnSubmitHandler( 306 | element.props[propNames.onSubmit] 307 | ); 308 | 309 | if (element.props[propNames.value] === undefined) { 310 | // Some input components (MUI) do not accept a `null` value. 311 | // For these, passing `{ nullValue: "" }` options does the trick. 312 | let inputValue = get(value, name); 313 | if (inputValue === null && nullValue !== undefined) 314 | inputValue = nullValue; 315 | newProps[propNames.value] = inputValue; 316 | } 317 | 318 | if (element.props[propNames.errors] === undefined) { 319 | newProps[propNames.errors] = filterErrorsForNames( 320 | errors, 321 | [name], 322 | false 323 | ); 324 | 325 | // Adjust the error names to correct scope 326 | if (element.type.isForm) { 327 | const canonicalName = bracketsToDots(name); 328 | newProps[propNames.errors] = newProps[propNames.errors].map( 329 | (err) => { 330 | return { 331 | ...err, 332 | name: bracketsToDots(err.name).slice( 333 | canonicalName.length + 1 334 | ), 335 | }; 336 | } 337 | ); 338 | } 339 | } 340 | 341 | newProps[propNames.hasBeenValidated] = hasBeenValidated; 342 | 343 | if (element.type.isFormInput) { 344 | if (typeof element.props[propNames.isReadOnly] === "function") { 345 | newProps[propNames.isReadOnly] = element.props.isReadOnly(value); 346 | } 347 | } 348 | 349 | newProps.ref = (el) => { 350 | this.elementRefs.push(el); 351 | }; 352 | } 353 | 354 | return newProps; 355 | }; 356 | return recursivelyCloneElements(children, propsFunc, (element) => { 357 | // Leave children of nested forms alone because they're handled by that form 358 | // Leave children of lists alone because the FormList component deals with duplicating them 359 | return element.type.isForm || element.type.isFormList; 360 | }); 361 | } 362 | 363 | render() { 364 | const { className, style } = this.props; 365 | 366 | return ( 367 |
368 | {this.renderFormFields()} 369 |
370 | ); 371 | } 372 | } 373 | 374 | Form.propTypes = { 375 | children: PropTypes.node.isRequired, 376 | className: PropTypes.string, 377 | errors: customPropTypes.errors, 378 | hasBeenValidated: PropTypes.bool, 379 | inputOptions: PropTypes.shape({ 380 | // eslint-disable-next-line react/forbid-prop-types 381 | nullValue: PropTypes.any, 382 | propNames: PropTypes.shape({ 383 | errors: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 384 | hasBeenValidated: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 385 | isReadOnly: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 386 | name: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 387 | onChange: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 388 | onChanging: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 389 | onSubmit: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 390 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 391 | }), 392 | }), 393 | logErrorsOnSubmit: PropTypes.bool, 394 | // Top-level forms and those under FormList do not need a name 395 | name: PropTypes.string, // eslint-disable-line react/no-unused-prop-types 396 | onChange: PropTypes.func, 397 | onChanging: PropTypes.func, 398 | onSubmit: PropTypes.func, 399 | revalidateOn: PropTypes.oneOf(["changing", "changed", "submit"]), 400 | style: PropTypes.object, // eslint-disable-line react/forbid-prop-types 401 | shouldSubmitWhenInvalid: PropTypes.bool, 402 | validateOn: PropTypes.oneOf(["changing", "changed", "submit"]), 403 | validator: PropTypes.func, 404 | value: PropTypes.object, // eslint-disable-line react/forbid-prop-types 405 | }; 406 | 407 | Form.defaultProps = { 408 | className: null, 409 | errors: undefined, 410 | hasBeenValidated: false, 411 | inputOptions: { 412 | nullValue: undefined, 413 | propNames: { 414 | errors: "errors", 415 | hasBeenValidated: "hasBeenValidated", 416 | isReadOnly: "isReadOnly", 417 | name: "name", 418 | onChange: "onChange", 419 | onChanging: "onChanging", 420 | onSubmit: "onSubmit", 421 | value: "value", 422 | }, 423 | }, 424 | logErrorsOnSubmit: false, 425 | name: null, 426 | onChange() {}, 427 | onChanging() {}, 428 | onSubmit() {}, 429 | revalidateOn: "changing", 430 | style: {}, 431 | shouldSubmitWhenInvalid: false, 432 | validateOn: "submit", 433 | validator: undefined, 434 | value: undefined, 435 | }; 436 | 437 | export default Form; 438 | -------------------------------------------------------------------------------- /lib/Form.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import renderer from "react-test-renderer"; 4 | import { Field, Input } from "reacto-form-inputs"; 5 | import Form from "./Form"; 6 | 7 | test("has isForm property set to true", () => { 8 | expect(Form.isForm).toBe(true); 9 | }); 10 | 11 | test("form snapshot 1", () => { 12 | const component = renderer.create( 13 |
14 | 15 |

Text above

16 | 17 |

Text below

18 |
19 |
20 | ); 21 | 22 | const tree = component.toJSON(); 23 | expect(tree).toMatchSnapshot(); 24 | }); 25 | 26 | test("form snapshot 2 - complex nesting", () => { 27 | const component = renderer.create( 28 |
29 | 30 |

Text above

31 | 32 |

Text below

33 |
34 |
35 |

Inner Form

36 | 37 | 38 |

Text above

39 | 40 |

Text below

41 |
42 | 43 |

Text above

44 | 45 |

Text below

46 |
47 | 48 |
49 | 50 | ); 51 | 52 | const tree = component.toJSON(); 53 | expect(tree).toMatchSnapshot(); 54 | }); 55 | 56 | test("sets value prop on child input for simple name", () => { 57 | const wrapper = mount( 58 |
59 | 60 |
61 | ); 62 | 63 | expect(wrapper.find("input").prop("value")).toBe("BAR"); 64 | }); 65 | 66 | test("sets value prop on child input for path name", () => { 67 | const wrapper = mount( 68 |
69 | 70 |
71 | ); 72 | 73 | expect(wrapper.find("input").prop("value")).toBe("VAL"); 74 | }); 75 | 76 | test("keeps child input value prop if present", () => { 77 | const wrapper = mount( 78 |
79 | 80 |
81 | ); 82 | 83 | expect(wrapper.find("input").prop("value")).toBe("DEFAULT"); 84 | }); 85 | 86 | test("sets value prop on nested descendant input", () => { 87 | const wrapper = mount( 88 |
89 |
90 | 91 |
92 | 93 |
94 |
95 |
96 |
97 | ); 98 | 99 | expect(wrapper.find("input").prop("value")).toBe("BAR"); 100 | }); 101 | 102 | test("simple form value is updated after user enters input", () => { 103 | const wrapper = mount( 104 |
105 | 106 |
107 | ); 108 | 109 | expect(wrapper.instance().getValue()).toEqual({ foo: "BAR" }); 110 | 111 | wrapper.find("input").simulate("change", { target: { value: "NEW" } }); 112 | 113 | expect(wrapper.instance().getValue()).toEqual({ foo: "NEW" }); 114 | }); 115 | 116 | test("path form value is updated after user enters input", () => { 117 | const wrapper = mount( 118 |
119 | 120 |
121 | ); 122 | 123 | expect(wrapper.instance().getValue()).toEqual({ foo: [{ a: "VAL" }] }); 124 | 125 | wrapper.find("input").simulate("change", { target: { value: "NEW" } }); 126 | 127 | expect(wrapper.instance().getValue()).toEqual({ foo: [{ a: "NEW" }] }); 128 | }); 129 | 130 | test("blurring input triggers form onChanging and onChange", () => { 131 | const onChange = jest.fn().mockName("onChange"); 132 | const onChanging = jest.fn().mockName("onChanging"); 133 | 134 | const wrapper = mount( 135 |
136 | 137 |
138 | ); 139 | 140 | expect(onChange).toHaveBeenCalledTimes(1); 141 | expect(onChanging).toHaveBeenCalledTimes(1); 142 | 143 | expect(onChange.mock.calls[0][0]).toEqual({ foo: null }); 144 | expect(onChanging.mock.calls[0][0]).toEqual({ foo: null }); 145 | 146 | onChange.mockClear(); 147 | onChanging.mockClear(); 148 | 149 | wrapper.find("input").simulate("blur", { target: { value: "NEW" } }); 150 | 151 | expect(onChange).toHaveBeenCalledTimes(1); 152 | expect(onChanging).toHaveBeenCalledTimes(1); 153 | 154 | expect(onChange.mock.calls[0][0]).toEqual({ foo: "NEW" }); 155 | expect(onChanging.mock.calls[0][0]).toEqual({ foo: "NEW" }); 156 | }); 157 | -------------------------------------------------------------------------------- /lib/FormList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import isEqual from "lodash/isEqual"; 4 | import clone from "clone"; 5 | 6 | import bracketsToDots from "./shared/bracketsToDots"; 7 | import customPropTypes from "./shared/propTypes"; 8 | import filterErrorsForNames from "./shared/filterErrorsForNames"; 9 | import recursivelyCloneElements from "./shared/recursivelyCloneElements"; 10 | 11 | const styles = { 12 | button: { 13 | paddingTop: 0, 14 | paddingRight: 0, 15 | paddingLeft: 0, 16 | paddingBottom: 0, 17 | width: "3.5rem", 18 | height: "3.5rem", 19 | verticalAlign: "-webkit-baseline-middle", 20 | }, 21 | item: { 22 | display: "flex", 23 | flexDirection: "row", 24 | borderWidth: 1, 25 | borderColor: "#cccccc", 26 | borderBottomStyle: "solid", 27 | borderTopStyle: "none", 28 | borderLeftStyle: "none", 29 | borderRightStyle: "none", 30 | paddingBottom: "1rem", 31 | marginBottom: "1rem", 32 | }, 33 | itemLeft: { 34 | paddingRight: "1.5rem", 35 | }, 36 | itemRight: { 37 | flexGrow: 1, 38 | }, 39 | lastItem: { 40 | display: "flex", 41 | flexDirection: "row", 42 | }, 43 | list: { 44 | borderWidth: 1, 45 | borderColor: "#cccccc", 46 | borderStyle: "solid", 47 | borderRadius: 3, 48 | padding: "1rem", 49 | }, 50 | addItemRow: { 51 | borderWidth: 1, 52 | borderColor: "#cccccc", 53 | borderBottomStyle: "none", 54 | borderTopStyle: "solid", 55 | borderLeftStyle: "none", 56 | borderRightStyle: "none", 57 | paddingTop: "1rem", 58 | marginTop: "1rem", 59 | }, 60 | }; 61 | 62 | class FormList extends Component { 63 | static isFormList = true; 64 | 65 | constructor(props) { 66 | super(props); 67 | 68 | let { minCount, value } = props; 69 | value = clone(value || []); 70 | minCount = minCount || 0; 71 | while (value.length < minCount) { 72 | value.push(null); 73 | } 74 | 75 | this.state = { value }; 76 | 77 | this.elementRefs = []; 78 | } 79 | 80 | // eslint-disable-next-line camelcase 81 | UNSAFE_componentWillMount() { 82 | const { value } = this.state; 83 | this.handleChanging(value); 84 | this.handleChanged(value); 85 | } 86 | 87 | // eslint-disable-next-line camelcase 88 | UNSAFE_componentWillReceiveProps(nextProps) { 89 | const { value } = this.props; 90 | let { minCount, value: nextValue } = nextProps; 91 | const { value: stateValue } = this.state; 92 | 93 | // Whenever a changed value prop comes in, we reset state to that, thus becoming clean. 94 | if (!isEqual(value, nextValue)) { 95 | nextValue = clone(nextValue || []); 96 | minCount = minCount || 0; 97 | while (nextValue.length < minCount) { 98 | nextValue.push(null); 99 | } 100 | 101 | // We also keep the array length the same so that items don't disappear and 102 | // confuse the user. 103 | while (nextValue.length < stateValue.length) { 104 | nextValue.push(null); 105 | } 106 | 107 | this.setState({ value: nextValue }); 108 | this.handleChanging(nextValue); 109 | this.handleChanged(nextValue); 110 | } 111 | } 112 | 113 | handleChanged(value) { 114 | const { onChange } = this.props; 115 | if (!isEqual(value, this.lastChangedValue)) { 116 | this.lastChangedValue = value; 117 | onChange(value); 118 | } 119 | } 120 | 121 | handleChanging(value) { 122 | const { onChanging } = this.props; 123 | if (!isEqual(value, this.lastChangingValue)) { 124 | this.lastChangingValue = value; 125 | onChanging(value); 126 | } 127 | } 128 | 129 | handleClickRemoveItem(index) { 130 | return () => { 131 | const { minCount } = this.props; 132 | const { value } = this.state; 133 | 134 | if (value.length === Math.max(minCount || 0, 0)) return; 135 | 136 | value.splice(index, 1); // mutation is ok because we cloned, and likely faster 137 | this.setState({ value }); 138 | this.handleChanging(value); 139 | this.handleChanged(value); 140 | }; 141 | } 142 | 143 | getFieldValueHandler(index, isChanged) { 144 | return (itemValue) => { 145 | const { value } = this.state; 146 | value[index] = itemValue; 147 | this.setState({ value }); 148 | if (isChanged) { 149 | this.handleChanged(value); 150 | } else { 151 | this.handleChanging(value); 152 | } 153 | }; 154 | } 155 | 156 | getValue() { 157 | return this.state.value; 158 | } 159 | 160 | handleClickAddItem = () => { 161 | const { value } = this.state; 162 | value.push(null); 163 | this.setState({ value }); 164 | }; 165 | 166 | resetValue() { 167 | let { minCount, value } = this.props; 168 | value = clone(value || []); 169 | minCount = minCount || 0; 170 | while (value.length < minCount) { 171 | value.push(null); 172 | } 173 | this.setState({ value }); 174 | 175 | this.elementRefs.forEach((element) => { 176 | if (element && typeof element.resetValue === "function") 177 | element.resetValue(); 178 | }); 179 | } 180 | 181 | renderArrayItems() { 182 | const { 183 | buttonClassName, 184 | buttonStyle, 185 | children, 186 | errors, 187 | itemAreaClassName, 188 | itemAreaStyle, 189 | itemClassName, 190 | itemStyle, 191 | itemRemoveAreaClassName, 192 | itemRemoveAreaStyle, 193 | minCount, 194 | name, 195 | onSubmit, 196 | removeButtonText, 197 | } = this.props; 198 | 199 | const { value } = this.state; 200 | 201 | // We'll do these checks just once, outside of the `value.map`, for speed. 202 | // This extra loop might be slower for small arrays, but will help with large arrays. 203 | let itemChild; 204 | let errorsChild; 205 | React.Children.forEach(children, (child) => { 206 | if (child.type.isFormList) { 207 | throw new Error( 208 | "reacto-form FormList: FormList may not be a child of FormList" 209 | ); 210 | } 211 | if (child.type.isForm || child.type.isFormInput) { 212 | if (itemChild) 213 | throw new Error( 214 | "reacto-form FormList: FormList must have exactly one Input or Form child" 215 | ); 216 | itemChild = child; 217 | } 218 | if (child.type.isFormErrors) { 219 | if (errorsChild) 220 | throw new Error( 221 | "reacto-form FormList: FormList may have no more than one ErrorsBlock child" 222 | ); 223 | errorsChild = child; 224 | } 225 | }); 226 | 227 | const hasMoreThanMinCount = value.length > Math.max(minCount || 0, 0); 228 | 229 | this.elementRefs = []; 230 | 231 | return value.map((itemValue, index) => { 232 | const itemName = `${name}[${index}]`; 233 | const kids = React.Children.map(children, (child) => { 234 | if (child.type.isForm || child.type.isFormInput) { 235 | let filteredErrors = filterErrorsForNames(errors, [itemName], false); 236 | // Adjust the error names to correct scope 237 | if (child.type.isForm) { 238 | filteredErrors = filteredErrors.map((err) => { 239 | return { 240 | ...err, 241 | name: bracketsToDots(err.name).slice( 242 | `${name}.${index}`.length + 1 243 | ), 244 | }; 245 | }); 246 | } 247 | 248 | return React.cloneElement( 249 | child, 250 | { 251 | errors: filteredErrors, 252 | name: itemName, 253 | onChange: this.getFieldValueHandler(index, true), 254 | onChanging: this.getFieldValueHandler(index, false), 255 | onSubmit, 256 | ref: (el) => { 257 | this.elementRefs.push(el); 258 | }, 259 | value: itemValue, 260 | }, 261 | recursivelyCloneElements(child.props.children) 262 | ); 263 | } 264 | 265 | if (child.type.isFormErrors) { 266 | return React.cloneElement( 267 | child, 268 | { 269 | errors: filterErrorsForNames(errors, [itemName], true), 270 | names: [itemName], 271 | }, 272 | recursivelyCloneElements(child.props.children) 273 | ); 274 | } 275 | 276 | return recursivelyCloneElements(child); 277 | }); 278 | 279 | let resolvedItemStyle = 280 | index + 1 === value.length ? styles.lastItem : styles.item; 281 | resolvedItemStyle = { ...resolvedItemStyle, ...itemStyle }; 282 | 283 | return ( 284 |
285 | {hasMoreThanMinCount && ( 286 |
290 | 298 |
299 | )} 300 |
304 | {kids} 305 |
306 |
307 | ); 308 | }); 309 | } 310 | 311 | renderAddItemButton() { 312 | const { 313 | addButtonText, 314 | addItemRowStyle, 315 | buttonClassName, 316 | itemClassName, 317 | } = this.props; 318 | 319 | const { value } = this.state; 320 | 321 | let resolvedStyle = value.length === 0 ? {} : styles.addItemRow; 322 | resolvedStyle = { ...resolvedStyle, ...addItemRowStyle }; 323 | 324 | return ( 325 |
326 | 334 |
335 | ); 336 | } 337 | 338 | render() { 339 | const { className, maxCount, style } = this.props; 340 | let { value } = this.state; 341 | if (!value) value = []; 342 | const hasFewerThanMaxCount = 343 | value.length < maxCount || maxCount === undefined || maxCount === null; 344 | 345 | return ( 346 |
347 | {this.renderArrayItems()} 348 | {hasFewerThanMaxCount && this.renderAddItemButton()} 349 |
350 | ); 351 | } 352 | } 353 | 354 | FormList.propTypes = { 355 | addButtonText: PropTypes.string, 356 | addItemRowStyle: PropTypes.object, // eslint-disable-line react/forbid-prop-types 357 | buttonClassName: PropTypes.string, 358 | buttonStyle: PropTypes.object, // eslint-disable-line react/forbid-prop-types 359 | children: PropTypes.node.isRequired, 360 | className: PropTypes.string, 361 | errors: customPropTypes.errors, 362 | itemAreaClassName: PropTypes.string, 363 | itemAreaStyle: PropTypes.object, // eslint-disable-line react/forbid-prop-types 364 | itemClassName: PropTypes.string, 365 | itemStyle: PropTypes.object, // eslint-disable-line react/forbid-prop-types 366 | itemRemoveAreaClassName: PropTypes.string, 367 | itemRemoveAreaStyle: PropTypes.object, // eslint-disable-line react/forbid-prop-types 368 | maxCount: PropTypes.number, 369 | minCount: PropTypes.number, 370 | name: PropTypes.string.isRequired, 371 | onChanging: PropTypes.func, 372 | onChange: PropTypes.func, 373 | onSubmit: PropTypes.func, 374 | removeButtonText: PropTypes.string, 375 | style: PropTypes.object, // eslint-disable-line react/forbid-prop-types 376 | value: PropTypes.arrayOf(PropTypes.any), 377 | }; 378 | 379 | FormList.defaultProps = { 380 | addButtonText: "+", 381 | addItemRowStyle: {}, 382 | buttonClassName: null, 383 | buttonStyle: {}, 384 | className: null, 385 | errors: undefined, 386 | itemAreaClassName: null, 387 | itemAreaStyle: {}, 388 | itemClassName: null, 389 | itemStyle: {}, 390 | itemRemoveAreaClassName: null, 391 | itemRemoveAreaStyle: {}, 392 | minCount: 0, 393 | maxCount: undefined, 394 | onChanging() {}, 395 | onChange() {}, 396 | onSubmit() {}, 397 | removeButtonText: "–", 398 | style: {}, 399 | value: undefined, 400 | }; 401 | 402 | export default FormList; 403 | -------------------------------------------------------------------------------- /lib/FormList.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import renderer from "react-test-renderer"; 3 | import { Input } from "reacto-form-inputs"; 4 | import Form from "./Form"; 5 | import FormList from "./FormList"; 6 | 7 | test("has isFormList property set to true", () => { 8 | expect(FormList.isFormList).toBe(true); 9 | }); 10 | 11 | test("snapshot Input child", () => { 12 | const component = renderer.create( 13 | 14 | 15 | 16 | ); 17 | 18 | const tree = component.toJSON(); 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | 22 | test("snapshot Form child", () => { 23 | const component = renderer.create( 24 | 28 |
29 | 30 |
31 |
32 | ); 33 | 34 | const tree = component.toJSON(); 35 | expect(tree).toMatchSnapshot(); 36 | }); 37 | 38 | test("snapshot Input child - fixed count", () => { 39 | const component = renderer.create( 40 | 46 | 47 | 48 | ); 49 | 50 | const tree = component.toJSON(); 51 | expect(tree).toMatchSnapshot(); 52 | }); 53 | 54 | test("snapshot Form child - fixed count", () => { 55 | const component = renderer.create( 56 | 62 |
63 | 64 |
65 |
66 | ); 67 | 68 | const tree = component.toJSON(); 69 | expect(tree).toMatchSnapshot(); 70 | }); 71 | -------------------------------------------------------------------------------- /lib/__snapshots__/Form.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`form snapshot 1 1`] = ` 4 |
8 |
12 | 17 |

18 | Text above 19 |

20 | 30 |

31 | Text below 32 |

33 |
34 |
35 | `; 36 | 37 | exports[`form snapshot 2 - complex nesting 1`] = ` 38 |
42 |
46 | 51 |

52 | Text above 53 |

54 | 64 |

65 | Text below 66 |

67 |
68 |
71 |

72 | Inner Form 73 |

74 |
78 |
82 | 87 |

88 | Text above 89 |

90 | 100 |

101 | Text below 102 |

103 |
104 |
108 | 113 |

114 | Text above 115 |

116 | 126 |

127 | Text below 128 |

129 |
130 |
131 |
132 |
133 | `; 134 | -------------------------------------------------------------------------------- /lib/__snapshots__/FormList.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`snapshot Form child - fixed count 1`] = ` 4 |
16 |
33 |
41 |
45 | 55 |
56 |
57 |
58 |
67 |
75 |
79 | 89 |
90 |
91 |
92 |
93 | `; 94 | 95 | exports[`snapshot Form child 1`] = ` 96 |
108 |
125 |
133 | 151 |
152 |
160 |
164 | 174 |
175 |
176 |
177 |
186 |
194 | 212 |
213 |
221 |
225 | 235 |
236 |
237 |
238 |
253 | 271 |
272 |
273 | `; 274 | 275 | exports[`snapshot Input child - fixed count 1`] = ` 276 |
288 |
305 |
313 | 323 |
324 |
325 |
334 |
342 | 352 |
353 |
354 |
355 | `; 356 | 357 | exports[`snapshot Input child 1`] = ` 358 |
370 |
387 |
395 | 413 |
414 |
422 | 432 |
433 |
434 |
443 |
451 | 469 |
470 |
478 | 488 |
489 |
490 |
505 | 523 |
524 |
525 | `; 526 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-named-as-default,import/no-named-as-default-member */ 2 | import Form from "./Form"; 3 | import FormList from "./FormList"; 4 | import muiOptions from "./muiOptions"; 5 | import useReactoForm from "./useReactoForm"; 6 | 7 | export { Form, FormList, muiOptions, useReactoForm }; 8 | -------------------------------------------------------------------------------- /lib/muiCheckboxOptions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | nullValue: false, 3 | onChangeGetValue: (event) => event.target.checked || false, 4 | propNames: { 5 | errors: false, 6 | hasBeenValidated: false, 7 | isReadOnly: "disabled", 8 | onChanging: false, 9 | onSubmit: false, 10 | value: "checked", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /lib/muiOptions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | nullValue: "", 3 | onChangeGetValue: (event) => event.target.value, 4 | onChangingGetValue: (event) => event.target.value, 5 | propNames: { 6 | errors: false, 7 | hasBeenValidated: false, 8 | isReadOnly: "disabled", 9 | onChange: "onBlur", 10 | onChanging: "onChange", 11 | onSubmit: false, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/shared/bracketsToDots.js: -------------------------------------------------------------------------------- 1 | import toPath from "lodash/toPath"; 2 | 3 | export default function bracketsToDots(pathString) { 4 | return toPath(pathString).join("."); 5 | } 6 | -------------------------------------------------------------------------------- /lib/shared/bracketsToDots.test.js: -------------------------------------------------------------------------------- 1 | import bracketsToDots from "./bracketsToDots"; 2 | 3 | test("bracketsToDots", () => { 4 | expect(bracketsToDots("a[0].b")).toBe("a.0.b"); 5 | expect(bracketsToDots("a[0].b[2]")).toBe("a.0.b.2"); 6 | expect(bracketsToDots("a[0].")).toBe("a.0."); 7 | expect(bracketsToDots("a.[0].")).toBe("a.0."); 8 | }); 9 | -------------------------------------------------------------------------------- /lib/shared/filterErrorsForNames.js: -------------------------------------------------------------------------------- 1 | import bracketsToDots from "./bracketsToDots"; 2 | 3 | export default function filterErrorsForNames(errors, names, exact) { 4 | if (!Array.isArray(errors) || !Array.isArray(names)) return []; 5 | 6 | // Accept paths that may contain brackets or dots, making them all dots 7 | names = names.map((name) => bracketsToDots(name)); 8 | 9 | return errors.filter((error) => { 10 | const errorName = bracketsToDots(error.name); 11 | return names.some((name) => { 12 | if (name === errorName) return true; 13 | return !exact && errorName.startsWith(`${name}.`); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /lib/shared/getDateFromDateTimeValues.js: -------------------------------------------------------------------------------- 1 | const oneOrTwoDigits = /^\d{1,2}$/; 2 | const upToFourDigits = /^\d{1,4}$/; 3 | const timeFormat = /^\d{1,2}:\d{2}$/; 4 | 5 | /** 6 | * Convert a Date instance to an object with properties for the various pieces 7 | * 8 | * @param {Object} obj - Object with properties for date/time pieces 9 | * @param {Moment} moment - An instance of moment.tz 10 | * @param {String} timezone - A timezone identifier 11 | */ 12 | export default function getDateFromDateTimeValues(obj, moment, timezone) { 13 | if (!moment || typeof timezone !== "string") return null; 14 | 15 | const { dayValue, monthValue, timeValue, yearValue } = obj; 16 | 17 | if (typeof dayValue !== "string" || !dayValue.match(oneOrTwoDigits)) 18 | return null; 19 | if ( 20 | typeof monthValue !== "string" || 21 | !monthValue.match(oneOrTwoDigits) || 22 | Number(monthValue) > 12 || 23 | Number(monthValue) < 1 24 | ) 25 | return null; 26 | if (typeof timeValue !== "string" || !timeValue.match(timeFormat)) 27 | return null; 28 | if (typeof yearValue !== "string" || !yearValue.match(upToFourDigits)) 29 | return null; 30 | 31 | const dateString = `${yearValue}-${monthValue}-${dayValue} ${timeValue}`; 32 | return moment.tz(dateString, "YYYY-M-DD H:mm", timezone).toDate(); 33 | } 34 | -------------------------------------------------------------------------------- /lib/shared/getDateTimeValuesFromDate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a Date instance to an object with properties for the various pieces 3 | * 4 | * @param {Function} propsFunc - Takes in element and returns a props object 5 | * @param {Moment} moment - An instance of moment.tz 6 | * @param {String} timezone - A timezone identifier 7 | */ 8 | export default function getDateTimeValuesFromDate(date, moment, timezone) { 9 | if ( 10 | !date || 11 | !(date instanceof Date) || 12 | !moment || 13 | typeof timezone !== "string" 14 | ) { 15 | // The defaults have to be "" rather than undefined or null so that React knows they are "controlled" inputs 16 | return { 17 | dayValue: "", 18 | monthValue: "", 19 | timeValue: "", 20 | yearValue: "", 21 | }; 22 | } 23 | const m = moment(date).tz(timezone); 24 | return { 25 | dayValue: m.format("DD"), 26 | monthValue: m.format("M"), 27 | timeValue: m.format("HH:mm"), 28 | yearValue: m.format("YYYY"), 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /lib/shared/propTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const customPropTypes = { 4 | errors: PropTypes.arrayOf( 5 | PropTypes.shape({ 6 | message: PropTypes.string.isRequired, 7 | name: PropTypes.string.isRequired, 8 | }) 9 | ), 10 | }; 11 | 12 | export default customPropTypes; 13 | -------------------------------------------------------------------------------- /lib/shared/recursivelyCloneElements.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * Recursively clone elements 5 | * 6 | * @param {React node} elements - React elements to traverse through. This can be an array or a single 7 | * element or null, so it's ok to pass the `children` prop directly. 8 | * @param {Function} [getProps] - A function that takes an element and returns props to be applied to the clone of that element 9 | * @param {Function} [shouldStopRecursing] - A function that takes an element and returns a truthy value if `recursivelyCloneElements` 10 | * should not be called for that element's children 11 | */ 12 | export default function recursivelyCloneElements( 13 | elements, 14 | getProps, 15 | shouldStopRecursing 16 | ) { 17 | const newElements = React.Children.map(elements, (element) => { 18 | if (!element || typeof element === "string" || !element.props) 19 | return element; 20 | 21 | if (typeof getProps !== "function") getProps = () => ({}); 22 | if (typeof shouldStopRecursing !== "function") 23 | shouldStopRecursing = () => false; 24 | 25 | const children = shouldStopRecursing(element) 26 | ? element.props.children 27 | : recursivelyCloneElements(element.props.children, getProps); 28 | 29 | return React.cloneElement(element, getProps(element), children); 30 | }); 31 | 32 | return Array.isArray(newElements) && newElements.length === 1 33 | ? newElements[0] 34 | : newElements; 35 | } 36 | -------------------------------------------------------------------------------- /lib/useReactoForm.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import clone from "clone"; 3 | import get from "lodash/get"; 4 | import isEqual from "lodash/isEqual"; 5 | import set from "lodash/set"; 6 | import bracketsToDots from "./shared/bracketsToDots"; 7 | import filterErrorsForNames from "./shared/filterErrorsForNames"; 8 | 9 | /** 10 | * @summary To ensure we do not mutate objects passed in, we'll do a deep clone. 11 | * @param {Any} value Any value 12 | * @return {Object} Cloned value 13 | */ 14 | function cloneValue(value) { 15 | return value ? clone(value) : {}; 16 | } 17 | 18 | /** 19 | * @summary Main ReactoForm hook 20 | */ 21 | export default function useReactoForm(props) { 22 | const { 23 | hasBeenValidated: hasBeenValidatedProp, 24 | logErrorsOnSubmit = false, 25 | onChange = () => {}, 26 | onChanging = () => {}, 27 | onSubmit = () => {}, 28 | revalidateOn = "changing", 29 | shouldSubmitWhenInvalid = false, 30 | validateOn = "submit", 31 | validator, 32 | value: valueProp, 33 | } = props; 34 | 35 | const [errors, setErrors] = useState([]); 36 | const [hasBeenValidated, setHasBeenValidated] = useState( 37 | hasBeenValidatedProp || false 38 | ); 39 | const [forceReset, setForceReset] = useState(0); 40 | const [formData, setFormData] = useState({}); 41 | 42 | // isReadOnly can be passed as a function, which is then called with 43 | // the current form data to determine whether it should be read only. 44 | let { isReadOnly } = props; 45 | if (typeof isReadOnly === "function") { 46 | isReadOnly = !!isReadOnly(formData); 47 | } 48 | 49 | /** 50 | * @summary Set field value in state using lodash set 51 | * @return {Object} A copy of formData, mutated 52 | */ 53 | function setFieldValueInFormData(fieldPath, fieldValue) { 54 | const formDataCopy = clone(formData); 55 | set(formDataCopy, fieldPath, fieldValue === undefined ? null : fieldValue); 56 | setFormData(formDataCopy); 57 | return formDataCopy; 58 | } 59 | 60 | // Whenever a changed value prop comes in, we reset state to that, thus becoming clean. 61 | useEffect(() => { 62 | setErrors([]); 63 | setHasBeenValidated(false); 64 | setFormData(cloneValue(valueProp)); 65 | }, [valueProp, forceReset]); 66 | 67 | // Let props override the `hasBeenValidated` state 68 | useEffect(() => { 69 | if ( 70 | typeof hasBeenValidatedProp === "boolean" && 71 | hasBeenValidatedProp !== hasBeenValidated 72 | ) { 73 | setHasBeenValidated(hasBeenValidatedProp); 74 | } 75 | }, [hasBeenValidatedProp]); 76 | 77 | /** 78 | * @summary Validate the form 79 | * @return {Promise} Promised array of error objects 80 | */ 81 | async function validateForm() { 82 | if (typeof validator !== "function") return []; 83 | 84 | let customValidatorErrors; 85 | try { 86 | customValidatorErrors = await validator(formData); 87 | } catch (error) { 88 | console.error(error); 89 | return []; 90 | } 91 | 92 | if (!Array.isArray(customValidatorErrors)) { 93 | console.error("validator function must return or promise an array"); 94 | return []; 95 | } 96 | 97 | setErrors(customValidatorErrors); 98 | setHasBeenValidated(true); 99 | 100 | return customValidatorErrors; 101 | } 102 | 103 | /** 104 | * 105 | */ 106 | async function submitForm() { 107 | const validationErrors = await validateForm(); 108 | 109 | if (logErrorsOnSubmit && validationErrors.length > 0) 110 | console.error(validationErrors); 111 | 112 | if (validationErrors.length && !shouldSubmitWhenInvalid) return null; 113 | 114 | // onSubmit should ideally return a Promise so that we can wait 115 | // for submission to complete, but we won't worry about it if it doesn't 116 | let submissionResult; 117 | try { 118 | submissionResult = await onSubmit( 119 | formData, 120 | validationErrors.length === 0 121 | ); 122 | } catch (error) { 123 | console.error('Form "onSubmit" function error:', error); 124 | return null; 125 | } 126 | 127 | const { ok = true, errors: submissionErrors } = submissionResult || {}; 128 | 129 | // Submission result must be an object with `ok` bool prop 130 | // and optional submission errors 131 | if (ok) { 132 | // Because `valueProp` is sometimes stale in this function, we have to 133 | // force a reset this way rather than by calling `setFormData` directly 134 | setForceReset(forceReset + 1); 135 | return null; 136 | } 137 | 138 | if (submissionErrors) { 139 | if (Array.isArray(submissionErrors)) { 140 | setErrors(submissionErrors); 141 | } else { 142 | console.error( 143 | 'onSubmit returned an object with "errors" property that is not a valid errors array' 144 | ); 145 | } 146 | } 147 | 148 | return null; 149 | } 150 | 151 | function getErrors(fieldPaths, { includeDescendantErrors = false } = {}) { 152 | if (!Array.isArray(fieldPaths)) { 153 | throw new Error( 154 | "First argument to getErrors must be an array of field paths" 155 | ); 156 | } 157 | return filterErrorsForNames(errors, fieldPaths, !includeDescendantErrors); 158 | } 159 | 160 | function getFirstError(fieldPaths, options) { 161 | const fieldErrors = getErrors(fieldPaths, options); 162 | if (fieldErrors.length === 0) return null; 163 | return fieldErrors[0]; 164 | } 165 | 166 | return { 167 | formData, 168 | getInputProps(fieldPath, getInputPropsOptions = {}) { 169 | const { 170 | isForm, 171 | nullValue, 172 | onChangeGetValue, 173 | onChangingGetValue, 174 | propNames = {}, 175 | } = getInputPropsOptions; 176 | 177 | let fieldErrors = filterErrorsForNames(errors, [fieldPath], false); 178 | 179 | // Adjust the error names to correct scope for forms 180 | if (isForm) { 181 | const canonicalName = bracketsToDots(fieldPath); 182 | fieldErrors = fieldErrors.map((err) => ({ 183 | ...err, 184 | name: bracketsToDots(err.name).slice(canonicalName.length + 1), 185 | })); 186 | } 187 | 188 | function onInputValueChange(...onChangeArgs) { 189 | // The composable forms spec calls for new value to be passed 190 | // directly as the first arg. Many popular libraries pass 191 | // an Event as the first arg, and `onChangeGetValue` can be 192 | // used to determine and return the new value. 193 | const inputValue = onChangeGetValue 194 | ? onChangeGetValue(...onChangeArgs) 195 | : onChangeArgs[0]; 196 | 197 | const updatedFormData = setFieldValueInFormData(fieldPath, inputValue); 198 | 199 | // Now bubble up the `onChange`, possibly validating first 200 | if ( 201 | validateOn === "changed" || 202 | validateOn === "changing" || 203 | (hasBeenValidated && 204 | (revalidateOn === "changed" || revalidateOn === "changing")) 205 | ) { 206 | validateForm() 207 | .then((updatedErrors) => { 208 | onChange(updatedFormData, updatedErrors.length === 0); 209 | return null; 210 | }) 211 | .catch((error) => { 212 | console.error(error); 213 | }); 214 | } else { 215 | onChange(updatedFormData, errors.length === 0); 216 | } 217 | } 218 | 219 | function onInputValueChanging(...onChangingArgs) { 220 | // The composable forms spec calls for new value to be passed 221 | // directly as the first arg. Many popular libraries pass 222 | // an Event as the first arg, and `onChangeGetValue` can be 223 | // used to determine and return the new value. 224 | const inputValue = onChangingGetValue 225 | ? onChangingGetValue(...onChangingArgs) 226 | : onChangingArgs[0]; 227 | 228 | const updatedFormData = setFieldValueInFormData(fieldPath, inputValue); 229 | 230 | if ( 231 | validateOn === "changing" || 232 | (hasBeenValidated && revalidateOn === "changing") 233 | ) { 234 | validateForm() 235 | .then((updatedErrors) => { 236 | onChanging(updatedFormData, updatedErrors.length === 0); 237 | return null; 238 | }) 239 | .catch((error) => { 240 | console.error(error); 241 | }); 242 | } else { 243 | onChanging(updatedFormData, errors.length === 0); 244 | } 245 | } 246 | 247 | // Some input components (MUI) do not accept a `null` value. 248 | // For these, passing `{ nullValue: "" }` options does the trick. 249 | let value = get(formData, fieldPath, null); 250 | if (value === null && nullValue !== undefined) value = nullValue; 251 | 252 | const inputProps = { 253 | [propNames.errors || "errors"]: fieldErrors, 254 | [propNames.hasBeenValidated || "hasBeenValidated"]: hasBeenValidated, 255 | [propNames.isReadOnly || "isReadOnly"]: isReadOnly, 256 | [propNames.name || "name"]: fieldPath, 257 | [propNames.onChange || "onChange"]: onInputValueChange, 258 | [propNames.onChanging || "onChanging"]: onInputValueChanging, 259 | [propNames.onSubmit || "onSubmit"]: submitForm, 260 | [propNames.value || "value"]: value, 261 | }; 262 | 263 | // If propNames key is set to `false`, omit the prop 264 | [ 265 | "errors", 266 | "hasBeenValidated", 267 | "isReadOnly", 268 | "name", 269 | "onChange", 270 | "onChanging", 271 | "onSubmit", 272 | "value", 273 | ].forEach((key) => { 274 | if (propNames[key] === false) delete inputProps[key]; 275 | }); 276 | 277 | return inputProps; 278 | }, 279 | getErrors, 280 | getFirstError, 281 | getFirstErrorMessage(fieldPaths, options) { 282 | const fieldError = getFirstError(fieldPaths, options); 283 | return (fieldError && fieldError.message) || null; 284 | }, 285 | hasBeenValidated, 286 | hasErrors(fieldPaths, options) { 287 | return getErrors(fieldPaths, options).length > 0; 288 | }, 289 | // Form is dirty if value prop doesn't match value state. Whenever a changed 290 | // value prop comes in, we reset state to that, thus becoming clean. 291 | isDirty: !isEqual(formData, valueProp), 292 | resetValue() { 293 | // Because `valueProp` is sometimes stale in this function, we have to 294 | // force a reset this way rather than by calling `setFormData` directly 295 | setForceReset(forceReset + 1); 296 | }, 297 | submitForm, 298 | }; 299 | } 300 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reacto-form", 3 | "version": "1.5.1", 4 | "description": "A reference implementation of the Composable Form Specification for React (see https://composableforms.netlify.app)", 5 | "author": "Long Shot Labs (https://www.longshotlabs.co/)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/longshotlabs/reacto-form.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/longshotlabs/reacto-form/issues" 13 | }, 14 | "homepage": "https://github.com/longshotlabs/reacto-form", 15 | "files": [ 16 | "CHANGELOG.md", 17 | "cjs", 18 | "esm", 19 | "LICENSE", 20 | "README.md" 21 | ], 22 | "browserslist": [ 23 | "last 2 version", 24 | "> 1%", 25 | "maintained node versions", 26 | "not dead" 27 | ], 28 | "eslintConfig": { 29 | "extends": [ 30 | "airbnb-base", 31 | "plugin:jsx-a11y/recommended", 32 | "plugin:react/recommended", 33 | "prettier" 34 | ], 35 | "parser": "@babel/eslint-parser", 36 | "env": { 37 | "browser": true, 38 | "jest": true 39 | }, 40 | "settings": { 41 | "react": { 42 | "version": "detect" 43 | } 44 | }, 45 | "rules": { 46 | "arrow-body-style": 0, 47 | "consistent-return": 0, 48 | "max-len": 0, 49 | "no-param-reassign": 0, 50 | "no-underscore-dangle": 0, 51 | "no-use-before-define": [ 52 | 2, 53 | "nofunc" 54 | ], 55 | "no-unused-expressions": 0, 56 | "no-console": 0, 57 | "space-before-function-paren": 0, 58 | "react/prefer-stateless-function": 0, 59 | "react/destructuring-assignment": 0, 60 | "react/no-multi-comp": 0, 61 | "react/jsx-filename-extension": 0, 62 | "jsx-a11y/href-no-hash": "off", 63 | "jsx-a11y/anchor-is-valid": [ 64 | "warn", 65 | { 66 | "aspects": [ 67 | "invalidHref" 68 | ] 69 | } 70 | ] 71 | } 72 | }, 73 | "jest": { 74 | "setupFilesAfterEnv": [ 75 | "raf/polyfill", 76 | "/jestSetup.js" 77 | ], 78 | "testEnvironment": "jsdom" 79 | }, 80 | "main": "./cjs/index.js", 81 | "module": "./esm/index.js", 82 | "scripts": { 83 | "build": "npm run build:modules && npm run build:common", 84 | "build:common": "rm -rf cjs/** && BABEL_ENV=production babel lib --out-dir cjs", 85 | "build:modules": "rm -rf esm/** && BABEL_ENV=production BABEL_MODULES=1 babel lib --out-dir esm", 86 | "lint": "BABEL_ENV=test eslint ./lib", 87 | "prepublishOnly": "npm run lint && npm test && npm run build", 88 | "test": "jest ./lib" 89 | }, 90 | "peerDependencies": { 91 | "react": ">=16.8 || >=17" 92 | }, 93 | "dependencies": { 94 | "@babel/runtime": "^7.27.0", 95 | "clone": "^2.1.2", 96 | "lodash": "^4.17.20", 97 | "prop-types": "^15.7.2" 98 | }, 99 | "devDependencies": { 100 | "@babel/cli": "^7.12.8", 101 | "@babel/core": "^7.16.0", 102 | "@babel/eslint-parser": "^7.16.3", 103 | "@babel/plugin-proposal-class-properties": "^7.16.0", 104 | "@babel/plugin-transform-runtime": "^7.16.0", 105 | "@babel/preset-env": "^7.16.0", 106 | "@babel/preset-react": "^7.16.0", 107 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.5", 108 | "composable-form-tests": "^1.1.0", 109 | "core-js": "^3.19.1", 110 | "enzyme": "^3.11.0", 111 | "eslint": "^8.2.0", 112 | "eslint-config-airbnb": "^19.0.0", 113 | "eslint-config-prettier": "^8.3.0", 114 | "eslint-plugin-import": "^2.25.3", 115 | "eslint-plugin-jsx-a11y": "^6.5.1", 116 | "eslint-plugin-react": "^7.27.0", 117 | "jest": "^27.3.1", 118 | "jsdom": "^18.1.0", 119 | "raf": "^3.4.1", 120 | "react": "^17.0.2", 121 | "react-dom": "^17.0.2", 122 | "react-test-renderer": "^17.0.2", 123 | "reacto-form-inputs": "^1.2.0" 124 | } 125 | } 126 | --------------------------------------------------------------------------------