├── .babelrc ├── .gitignore ├── .npmignore ├── .nvmrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── migrate-1.2-to-1.3.md └── schema-onchange.md ├── jsdoc2md └── README.hbs ├── package-lock.json ├── package.json ├── src ├── autoform_state.js ├── coercing.js ├── createSchema.js ├── index.js ├── index_base.js ├── pubsub.js ├── translate.js ├── translation_utils.js ├── translations │ ├── en.js │ ├── es.js │ └── index.js ├── ui │ ├── Autofield.jsx │ ├── AutofieldContainer.jsx │ ├── Autoform.jsx │ ├── AutoformBase.jsx │ ├── baseSkin.js │ ├── componentRender.jsx │ ├── components │ │ ├── Button.jsx │ │ ├── Checkbox.jsx │ │ ├── FieldPropsOverride.jsx │ │ ├── InputArrayPanel.jsx │ │ ├── InputArrayTable.jsx │ │ ├── InputArrayWrap.jsx │ │ ├── InputWrap.jsx │ │ ├── Panel.jsx │ │ ├── Radio.jsx │ │ ├── RadiosWrap.jsx │ │ ├── Select.jsx │ │ ├── Submodel.jsx │ │ └── index.js │ ├── defaultSkin.jsx │ ├── deletedMark.js │ ├── ducks │ │ ├── index.js │ │ └── inputArray.js │ └── svgs │ │ ├── AddGlyph.jsx │ │ ├── RemoveGlyph.jsx │ │ └── svgUtils.js └── utils.js ├── test ├── addProps.react.test.js ├── coerce.react.test.js ├── controlled.react.test.js ├── custom-element.react.test.js ├── default-values.react.test.js ├── defaultSkin │ ├── array.react.test.js │ ├── boolean.react.test.js │ ├── number.react.test.js │ ├── password.react.test.js │ ├── radios.react.test.js │ ├── range.react.test.js │ ├── select.react.test.js │ └── subschema.react.test.js ├── field-override.react.test.js ├── forceErrors.react.test.js ├── form.react.test.js ├── helperText.react.test.js ├── imperative.react.test.js ├── initialization.react.test.js ├── inputArray.react.test.js ├── noAutocomplete.react.test.js ├── options.react.test.js ├── schema-onChange.react.test.js ├── submit.react.test.js ├── throws.test.js ├── translation.test.js ├── utils │ ├── ErrorBoundary.jsx │ ├── _mutationObserverHack.js │ ├── buttonHack.js │ ├── changeField.js │ ├── createParenter.js │ ├── createSubmitMocks.js │ └── enzymeConfig.js └── validations.react.test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "node": true 6 | } 7 | }], 8 | [ "@babel/preset-react", { 9 | "runtime": "automatic" 10 | }] 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-transform-spread", 14 | "@babel/plugin-transform-object-rest-spread", 15 | "@babel/plugin-transform-class-properties", 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | node_modules 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "11.10.1" 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm test 11 | 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.15 4 | 5 | * Update dependencies 6 | 7 | ## 1.3.14 8 | 9 | ### Fixed 10 | 11 | * `validate` validation will correctly translated return value (`true` for "ok") 12 | * Passing `errorText` to wrapped array handler 13 | 14 | ## 1.3.13 15 | 16 | ### Changed 17 | 18 | * Array values are overwritten from initialValues instead of appending them. 19 | * Array mode can be overridden in fieldSchema. 20 | * Array field name digs into `_field` string id. 21 | 22 | ## 1.3.12 23 | 24 | ### Added 25 | 26 | * `getValues()` to the imperative handlers that returns the complete coerced form values 27 | * Tests for the rest of the imperative functions 28 | * Test for controlled components 29 | 30 | ### Fixed 31 | 32 | * Fixed imperative `reset()` 33 | 34 | ## 1.3.11 35 | 36 | ### Added 37 | 38 | * Allow React 18 as peer dependency 39 | 40 | ## 1.3.10 41 | 42 | ### Fixed 43 | 44 | * Externals errors with `forceErrors` now work with nested fields. 45 | 46 | ## 1.3.9 47 | 48 | ### Added 49 | 50 | * Be able to set errors from outside (for example, server) with `forceErrors` param in `Autoform` 51 | 52 | ## 1.3.8 53 | 54 | ### Fixed 55 | 56 | * Fixed mistake that locked react to 17 in peerDependencies. 57 | 58 | ## 1.3.7 59 | 60 | ### Changed 61 | 62 | * Updated some modules to fix security advisories 63 | * In webpack, separated mode from minification so there can be no minimized build that is still production like `npm run build:umd` 64 | 65 | ## 1.3.6 66 | 67 | * Put labels in some readme functions 68 | * Allow to add strings to specific language 69 | * Allow to change string base path with `trPathSetBase` 70 | * ``'s `onChange` prop to listen to any document changes 71 | * Renamed skin `render` to `props` to make it clearer. The older `render` still works and I don't plan on deprecating it. 72 | 73 | ## 1.3.5 74 | 75 | * Correct formatting in `docs/schema-onchange.md` 76 | * `setVisible` also prevents field occurrence in the submit results 77 | * Pass correctly `ref` in default skin in order for checkboxes to work 78 | 79 | ## 1.3.4 80 | 81 | ### Fixed 82 | 83 | * default skin select will receive correct initial values 84 | 85 | ## 1.3.3 86 | 87 | ### Added 88 | 89 | * Pass `inputRef` to skin function components to allow them to have the value changed without need them to be controlled 90 | * Pass `helperText` to skin function components too. Helps material-ui. 91 | 92 | ## 1.3.2 93 | 94 | ### Added 95 | 96 | * Tests for schema `onChange` 97 | * Talk about creating errors with `onChange` and test it 98 | * The helper text is specified with `setHelperText` from `onChange`, then schema `helperText` then model translated string `models..._helper` then blank. 99 | 100 | ### Changed 101 | 102 | * `FieldPropsOverride`'s `onChange` now works like schema's and receives [form control context](https://github.com/dgonz64/react-hook-form-auto/blob/master/docs/schema-onchange.md#context). 103 | * `onChange` is not considered experimental anymore 104 | 105 | ### Fixed 106 | 107 | * Select type schema `onChange` value passing corrected 108 | * More resilient way to create autofield state control 109 | 110 | ## 1.3.1 111 | 112 | ### Fixed 113 | 114 | * Stop ignoring `dist` folder 115 | 116 | ## 1.3.0 117 | 118 | ### Added 119 | 120 | * Compatibility with react-hook-form 7 121 | * [Guide for skin migrations](docs/migrate-1.2-to-1.3.md) 122 | * Inputs are bound individually to errors. That reduces whole form re-renders. 123 | * EXPERIMENTAL: Allows field value reaction with `onChange` in the schema definition. [Documentation](docs/schema-onchange.md) 124 | * Schemas can set a helper text for the field with `helperText` 125 | * (skins) `react-hook-form-auto` now takes care of register and not the skin. The skin will receive `onChange` and `onBlur` 126 | * (skins) Can take care of the logic for controlled components. Just add `controlled: true` to your skin entry and the skin component will receive also `value`. 127 | * (skins) Can choose a name for error field with `nameForErrors` skin attribute 128 | 129 | ### Changed 130 | 131 | * `onErrors` prop of `Autoform` is only called in submit 132 | * label's `for` and field's `id` now include the schema name in order to avoid potential collisions 133 | * `InputWrap` is now in charge of `array` type error display 134 | * Skin documentation was updated 135 | * (skins) `errors` are now only one and it's a string called `errorText`. 136 | * (skins) `array` the fields now unregister removed subobjects 137 | * (internal) `objectTraverse` now supports `returnValue` option that returns the value instead of its context 138 | * (internal) Tests updated for the new DOM layout 139 | * (internal) `componentRender` refactor, separated to `componentRender`, `AutofieldContainer` and `AutoField` 140 | * (internal, important for tests) field names follow the new `react-hook-form` dotted syntax. You can continue to use the bracked syntax in `FieldPropsOverride` for example. 141 | * (internal) `coerceRef` becomes `stateRef` and holds state, not only value. Also its keys are now paths instead of a fully structured doc. 142 | 143 | ### Fixed 144 | 145 | * Make clear in docs that you need version 6 of `react-hook-form` for older versions of this library in the deprecation section 146 | * Uncontrolled components with only update individually when there are errors instead of rendering the whole tree 147 | * `array` type component now unregister 148 | 149 | ### Removed 150 | 151 | * Removed typescript from project roadmap. Pull requests are welcome. 152 | 153 | ## 1.2.11 154 | 155 | ### Added 156 | 157 | * React 7 support 158 | 159 | ### Fixed 160 | 161 | * Updated some modules for security 162 | * Removed unneeded field triggers 163 | 164 | ## 1.2.10 165 | 166 | ### Fixed 167 | 168 | * Fixed FieldPropsOverride overriding everything 169 | 170 | ## 1.2.9 171 | 172 | ### Fixed 173 | 174 | * Fixed FieldPropsOverride identification after minification 175 | 176 | ## 1.2.8 177 | 178 | ### Added 179 | 180 | * You can affect whole array fields with a more semantic `FieldPropsOverride` name. 181 | 182 | ## 1.2.7 183 | 184 | ### Added 185 | 186 | * Actual example as to how to specify skin overrides. 187 | * Update React Native status. 188 | * Pass overrides also separately in the `overrides` prop. 189 | 190 | ### Fixed 191 | 192 | * Some security concerns 193 | 194 | ## 1.2.6 195 | 196 | ### Added 197 | 198 | * Now passing `styles` to `addGlyph` and `removeGlyph` skin components. 199 | * Talk about React Native project. 200 | 201 | ## 1.2.5 202 | 203 | ### Added 204 | 205 | * Validation rules are passed to skin component in order to help with ``. 206 | * Instance method `reset()` that works with ``. 207 | 208 | ### Fixed 209 | 210 | * Now exporting render functions. Rarely needed but documented. 211 | * (Internal) Moved `trPath()` to `translation_utils.js`. 212 | 213 | ## 1.2.4 214 | 215 | ### Added 216 | 217 | * Exporting AutoformBase to allow for more generic skins: Library is now compatible with ReactNative. 218 | * Every component is now configurable. 219 | 220 | ## 1.2.3 221 | 222 | ### Added 223 | 224 | * Some basic error messages on obvious things that we programmers usually forget, like not passing a `schema`. 225 | * Implemented minChildren and maxChildren 226 | 227 | ### Fixed 228 | 229 | * Forgot to talk about blueprint ui in README 230 | * Actually export `InputWrap` as documentation refers to it. 231 | * Array children now receive validation errors correctly. 232 | 233 | ## 1.2.2 234 | 235 | ### Added 236 | 237 | * Now you can control autocomplete="off" from Autoform (general) and schema (individual) see README.md 238 | * You can call `stringExists(id)` to see if it exists 239 | * Be able to pass any prop from schema to wrapper and input 240 | * Talk about brand new blueprintjs skin 241 | 242 | ### Changed 243 | 244 | * The X symbol has been removed from the errored input 245 | 246 | ## 1.2.1 247 | 248 | * Fixing package.lock 249 | 250 | ## 1.2.0 251 | 252 | ### Added 253 | 254 | * Allow wrapper to be specified also per component type in skin 255 | 256 | ### Changed 257 | 258 | * Updated dependencies 259 | * Using ReactHookForm 6 260 | * defaultValue is calculated before skinning and not after 261 | * Slightly better required string 262 | 263 | ### Fixed 264 | 265 | * Better boolean result for boolean fields 266 | * minimist security concern 267 | * acorn security concern 268 | * Updated docs with sandbox demos 269 | 270 | ## 1.1.2 271 | 272 | ### Changed 273 | 274 | * `register` will be passed to skin components with validation already set up 275 | * Documented the way to use `register` in skins 276 | 277 | ## 1.1.1 278 | 279 | ### Added 280 | 281 | * Error translation moved to translation_utils.js 282 | * Export more functions to help constructing skins 283 | * trField to translate field names 284 | * Allow processOptions to manage with standard control props 285 | * Allow button and form skinage 286 | * Pass submit to the form button onClick in order to facilitate imperative use 287 | * Added more components to skin to allow easier and more granular skinage 288 | 289 | ### Fixed 290 | 291 | * Integrate all README changes into generator (jsdoc2md/README.hbs) that were put in the README by mistake. 292 | * Incorrect translations for min, max, minLength and maxLength 293 | 294 | ## 1.1.0 295 | 296 | ### Added 297 | 298 | * You can change document values outside of Autoform 299 | * Documentation about Autoform's ref 300 | * Passing ReacHookForm's formHook to form components 301 | * Components get autoform props in autoformProps prop 302 | * Components get information about their place in arrays 303 | * Works with ReactHookForm 4 304 | 305 | ## 1.0.3 (12/11/2019) 306 | 307 | ### Fixed 308 | 309 | * Fixed checkboxes 310 | * Some documentation formating concerning translations 311 | 312 | ### Added 313 | 314 | * Tests for more components in the submit test 315 | 316 | ## 1.0.2 (26/10/2019) 317 | 318 | ### Added 319 | 320 | * Add and remove items from arrays is done with svg now 321 | * Sample and documentation for styling with Bootstrap 4 322 | 323 | ## 1.0.1 (14/10/2019) 324 | 325 | ### Updated 326 | 327 | * Better documentation concerning styles 328 | 329 | ## 1.0.0 (13/10/2019) 330 | 331 | Initial! :metal: 332 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | #### Bugs 2 | 3 | If you find a bug, open an issue. There will be probably needed code samples or, better, a project that reproduces the bug. In any case, provide as much information as you can about the expectations vs reality. 4 | 5 | #### Coding 6 | 7 | Some pointers in [README](https://github.com/dgonz64/react-hook-form-auto#help-wanted--contribute) 8 | 9 | ## Thank you very much! 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 David González 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/migrate-1.2-to-1.3.md: -------------------------------------------------------------------------------- 1 | # Affects skins 2 | 3 | After a [good question](https://github.com/dgonz64/react-hook-form-auto/issues/16) I though the best way to manage field change reaction was to upgrade to react-hook-form 7 because tracking `ref` felt kind of hacky. If you don't have skin components, you are good to go. Just update corresponding the library of the family, and react-hook-form. 4 | 5 | If you made a skin, I hope this migration guide helps. 6 | 7 | When I refer to component I mean the `skin[type].component` (or `skin[type].props.component`) entry. 8 | 9 | ## register vs onChange and onBlur 10 | 11 | The biggest difference is that now inputs are automatically registered and input component has `onChange` and `onBlur` as props that come from `const { field } = register()`. You should use those in the skin instead of `ref` or calling `register()` manually. 12 | 13 | ```diff 14 | component: (props) => { 15 | const { 16 | id, 17 | name, 18 | - register, 19 | defaultValue, 20 | + onChange, 21 | + onBlur 22 | } = props 23 | 24 | return ( 25 | 33 | ) 34 | } 35 | ``` 36 | 37 | ## setValue 38 | 39 | Still works for plain value (not event), both for controlled and uncontrolled inputs. 40 | 41 | ```diff 42 | component: (props) => { 43 | const { 44 | name, 45 | defaultValue, 46 | + setValue, 47 | + onBlur 48 | } = props 49 | 50 | + const handleChange = newValue => { 51 | + setValue(name, newValue) 52 | + } 53 | + 54 | return ( 55 | 61 | ) 62 | } 63 | } 64 | 65 | ``` 66 | 67 | ## id 68 | 69 | Components now receive id 70 | 71 | ```diff 72 | component: (props) => { 73 | const { 74 | + id, 75 | name, 76 | } = props 77 | 78 | return ( 79 | 84 | ) 85 | } 86 | 87 | ``` 88 | 89 | ## Controlled 90 | 91 | If the skin resolver is declared as controlled, example: 92 | 93 | ```javascript 94 | string: { 95 | controlled: true, 96 | component: ({ id, name, onChange, onBlur, value }) => { 97 | ... 98 | } 99 | }, 100 | ``` 101 | 102 | Then `skin[type].component` (or `skin[type].props.component`) will be rendered with `value` prop updated each time it changes. 103 | 104 | ## errorText 105 | 106 | Instead of an error object skin receives now `errorText` as an already translated string. 107 | 108 | ```diff 109 | const ControlAdaptor = ({ 110 | name, 111 | - errors, 112 | + errorText, 113 | }) => { 114 | - const error = errors[field] 115 | - const errorText = typeof error == 'object' ? tr(error.message, fieldSchema) : '' 116 | 117 | return
{errorText}
118 | } 119 | ``` 120 | -------------------------------------------------------------------------------- /docs/schema-onchange.md: -------------------------------------------------------------------------------- 1 | # Schema's onChange 2 | 3 | Now schemas can make the form react to changes. To acomplish that you need to set `onChange` accordingly. 4 | 5 | It's done by intercepting both `onChange` and `setValue`. 6 | 7 | Consider this example: 8 | 9 | ```javascript 10 | const pet = createSchema('pet', { 11 | name: { 12 | type: String, 13 | required: true, 14 | maxLength: 8 15 | }, 16 | heads: { 17 | type: Number, 18 | onChange: (value, { arrayControl }) => { 19 | if (value == '42') 20 | arrayControl.add() 21 | if (value == '13') 22 | arrayControl.remove(arrayControl.index) 23 | }, 24 | helperText: 'Enter 42 to add and 13 to remove' 25 | }, 26 | hair: { 27 | type: 'select', 28 | options: ['blue', 'yellow'], 29 | onChange: (value, { name, setHelperText }) => { 30 | setHelperText(name, `Better not choose ${value}`) 31 | } 32 | }, 33 | }); 34 | 35 | return createSchema('owner', { 36 | name: { 37 | type: 'string', 38 | required: true, 39 | }, 40 | height: { 41 | type: 'radios', 42 | options: ['tall', 'short'], 43 | onChange: (value, { setValue }) => { 44 | if (value == 'tall') 45 | setValue('name', 'André the Giant') 46 | } 47 | }, 48 | usesHat: { 49 | type: 'boolean', 50 | onChange: (value, { setVisible }) => { 51 | setVisible('hatColor', value) 52 | } 53 | }, 54 | hatColor: { 55 | type: 'select', 56 | options: ['black', 'red'], 57 | initiallyVisible: false 58 | }, 59 | pets: { 60 | type: [pet], 61 | minChildren: 1, 62 | maxChildren: 2 63 | } 64 | }); 65 | ``` 66 | 67 | You can copy and paste it directly in [the demo](https://dgonz64.github.io/react-hook-form-auto-demo/demo/). You will be able to: 68 | 69 | * Automatically set owner's name when you set tall as height 70 | * Show an extra field when owner wears hat 71 | * Add pets when one pet has 42 heads 72 | * Remove pet when it has 13 heads 73 | * Receive conflicting advice when selecting pet hair color 74 | 75 | # API 76 | 77 | Schema's `onChange` receives the following arguments: 78 | 79 | | Param | Type | Description | 80 | | --- | --- | --- | 81 | | value | any | New plain value already set for the field the schema refers to | 82 | | context | object | Utilities to change state | 83 | 84 | # `context` 85 | 86 | The `context` has information and allows you to operate fields. It's done with a rudimentary pub-sub system, so only the affected field is updated. It has the following attributes: 87 | 88 | | Param | Type | Description | 89 | | --- | --- | --- | 90 | | name | string | Full field path. Name compatible with all the utilities | 91 | | setVisible | function | `setVisible(name, visible)` changes the visibility of the field | 92 | | setHelperText | function | `setHelperText(name, text)` changes the text that appears below the field | 93 | | formHook | object | As returned by [React Hook Form `useForm`](https://react-hook-form.com/api/useform) (`register`, `unregister`, etc) | 94 | | setValue | function | `setValue(name, value)` Sets the value of the field | 95 | | arrayControl | object | Utilities to change array in the case the field is an array | 96 | 97 | With `formHook` you can, for example, create errors: 98 | 99 | ```javascript 100 | const owner = createSchema('owner', { 101 | name: { 102 | // ... 103 | onChange: (value, { formHook }) => { 104 | if (value == 'errorsy') { 105 | formHook.setError('height', { 106 | type: 'focus', 107 | message: 'Something something error' 108 | }) 109 | } 110 | } 111 | } 112 | }) 113 | ``` 114 | 115 | ## `arrayControl` 116 | 117 | If the field is an array, `arrayControl` has the following attributes 118 | 119 | | Param | Type | Description | 120 | | --- | --- | --- | 121 | | items | array | Items state | 122 | | index | number | Index of the current field | 123 | | remove | function | `remove(index)` removes element with index `index` | 124 | | add | function | Adds a new element to the array | 125 | -------------------------------------------------------------------------------- /jsdoc2md/README.hbs: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/dgonz64/react-hook-form-auto.svg?branch=master)](https://travis-ci.org/dgonz64/react-hook-form-auto) 2 | 3 | This library allows your React application to automatically generate forms using [ReactHookForm](https://react-hook-form.com/). The form and validations are generated following a schema inspired by [SimpleSchema](https://github.com/aldeed/simple-schema-js). 4 | 5 | ## New in 1.3.0 6 | 7 | Now works with React Hook Form 7 🎉 8 | 9 | All the members of the family, like `rhfa-react-native` are updated too. You can update the corresponding one and it should work. If not, please open an issue. 10 | 11 | The exception is if you made a skin for your project, then you should follow [this guide](docs/migrate-1.2-to-1.3.md). 12 | 13 | ## Contents 14 | 15 | * [Installation](#installation) 16 | * [Usage](#usage) 17 | * [1. Write schema](#1-write-schema) 18 | * [2. Render a form](#2-render-a-form) 19 | * [3. Make it prettier](#3-make-it-prettier) 20 | * [4. Translate labels](#4-translate) 21 | * [Available skins](#available-skins) 22 | * [Roadmap](#roadmap) 23 | * [Documentation](#documentation) 24 | * [Types](#types) 25 | * [Select and radios](#select-and-radios) 26 | * [Validators](#validators) 27 | * [`validate` validator](#the-validate-validator) 28 | * [Other schema fields](#other-schema-fields) 29 | * [Schema onChange](docs/schema-onchange.md) 30 | * [Schema](#schema) 31 | * [`Autoform` component](#autoform-component) 32 | * [Config](#config) 33 | * [Field props override](#field-props-override) 34 | * [Coercers](#coercers) 35 | * [Forcing errors](#forcing-errors) 36 | * [Imperative handling](#imperative-handling) 37 | * [Translation](#translation) 38 | * [`tr()`](#tr) 39 | * [`stringExists()`](#stringexists) 40 | * [Variable substitution](#variable-substitution) 41 | * [Adding strings](#adding-strings) 42 | * [Multilanguage](#multilanguage) 43 | * [Translation utils](#translation-utils) 44 | * [Use your own](#use-your-own-translation-system) 45 | * [Extending](#extending) 46 | * [Extending tutorial](https://github.com/dgonz64/rhfa-playground) 47 | * [With styles](#with-styles) 48 | * [Overriding skin](#overriding-skin) 49 | * [`coerce`](#coerce) 50 | * [`props`](#props) 51 | * [object](#props-is-an-object) 52 | * [function](#props-is-a-function) 53 | * [`onChange`, `onBlur`](#onchange-and-onblur-in-props) 54 | * [Directly](#use-it-directly-recommended) 55 | * [`setValue`](#use-setvalue) 56 | * [Other skin attributes](#other-skin-block-attributes) 57 | * [`skin[type].props`](#skintypeprops) 58 | * [`skin[type].component`](#skintypecomponent-or-skintypepropscomponent) 59 | * [Importing base](#importing-base) 60 | 61 | ## Play with the demos 62 | 63 | * [Emergency styles](https://codesandbox.io/s/rhfa-emergency-6upsj?file=/src/App.js) 64 | * [Bootstrap 4](https://codesandbox.io/s/rhfa-bootstrap-ttbfw?file=/src/App.js) 65 | * [Material-UI](https://codesandbox.io/s/rhfa-material-ui-k9pe9?file=/src/App.js) 66 | * [Blueprint](https://codesandbox.io/s/rhfa-blueprint-l4773?file=/src/App.js) 67 | 68 | ### Full webpack project demos 69 | 70 | * [Play with the bootstrap4 demo](https://dgonz64.github.io/react-hook-form-auto-demo-bootstrap4/demo/). [Project](https://github.com/dgonz64/react-hook-form-auto-demo-bootstrap4). 71 | * [Play with emergency styles demo](https://dgonz64.github.io/react-hook-form-auto-demo/demo). [Project](https://github.com/dgonz64/react-hook-form-auto-demo). 72 | * [Play with Material-UI demo](https://dgonz64.github.io/rhfa-demo-material-ui/demo/). [Project](https://github.com/dgonz64/rhfa-demo-material-ui) 73 | * [React Native](https://github.com/dgonz64/rhfa-demo-react-native) 74 | 75 | ## Installation 76 | 77 | $ npm install react-hook-form react-hook-form-auto --save 78 | 79 | ## Version deprecations 80 | 81 | * 1.3.0 works with react-hook-form 7. 82 | * If you didn't override the skin, it should work out of the box after update. 83 | * If you overrided the skin, then follow [this guide](docs/migrate-1.2-to-1.3.md). 84 | * 1.2.0 works with react-hook-form 6: `npm install react-hook-form@6 react-hook-form-auto@1.2 --save` 85 | * 1.1.0 works with react-hook-form 4 and 5. Older versions of this library (1.0.x) will only work with version 3 of react-hook-form. 86 | 87 | ## Usage 88 | 89 | ### 1. Write schema 90 | 91 | Write a schema for each model you have: 92 | 93 | ```javascript 94 | import { createSchema } from 'react-hook-form-auto' 95 | 96 | export const client = createSchema('client', { 97 | name: { 98 | type: 'string', 99 | required: true, 100 | max: 32 101 | }, 102 | age: { 103 | type: 'number' 104 | } 105 | }) 106 | ``` 107 | 108 | In this example we are stating that a `client` is required to have a `name` and providing its allowed length. Also `client` has `age` and it's a number. 109 | 110 | ### 2. Render a form 111 | 112 | `` React component will generate inputs including translatable label, proper input types and error messages. 113 | 114 | ```javascript 115 | import { Autoform } from 'react-hook-form-auto' 116 | import { client } from './models/client' 117 | 118 | const MyForm = ({ onSubmit }) => 119 | 123 | ``` 124 | 125 | Form will be validated following the rules set by the schema. 126 | 127 | It also allows you to build arrays from other schemas. Simply specify the other schema within brackets `[]`. `Autoform` default skin will allow you to add and remove elements. 128 | 129 | ```javascript 130 | import { createSchema } from 'react-hook-form-auto' 131 | import { client } from './client' 132 | 133 | export const company = createSchema('company', { 134 | clients: { 135 | type: [client], 136 | minChildren: 10, 137 | arrayMode: 'panel' // You can override config 138 | } 139 | }) 140 | ``` 141 | 142 | You can override form array config for a particular field using `arrayMode` 143 | 144 | ### 3. Make it prettier 145 | 146 | #### 3a. Make it less ugly with some styling 147 | 148 | Install the emergency styles if you don't want to bundle a whole css library. 149 | 150 | $ npm install rhfa-emergency-styles --save 151 | 152 | Then set the `styles` prop of ``: 153 | 154 | ```javascript 155 | import styles from 'rhfa-emergency-styles' 156 | 157 | // With sass... 158 | import 'rhfa-emergency-styles/prefixed.sass' 159 | // ...or without 160 | import 'rhfa-emergency-styles/dist/styles.css' 161 | 162 | 163 | ``` 164 | 165 | If you use `sass` you have to make sure you are [not excluding `node_modules`](https://github.com/dgonz64/react-hook-form-auto-demo/commit/94dbe78dc93a4110f915a5809a6880a8c7a55970) in your build process. 166 | 167 | If you use `css-modules` you have [better options](https://github.com/dgonz64/rhfa-emergency-styles). 168 | 169 | #### 3b. Make it pretty with Bootstrap 4 170 | 171 | We can take advantage of the styling strategy and pass bootstrap classes as `styles` props. You can grab them [from here](https://github.com/dgonz64/react-hook-form-auto-demo-bootstrap4/blob/master/src/styles.js) \[[raw](https://raw.githubusercontent.com/dgonz64/react-hook-form-auto-demo-bootstrap4/master/src/styles.js)\]. Then use them: 172 | 173 | ```javascript 174 | import styles from './bsStyles' // copy-pasted styles description 175 | 176 | 177 | ``` 178 | 179 | As you have to pass the styles on every `Autoform` render, I recommend [creating a module](https://github.com/dgonz64/react-hook-form-auto-demo/blob/master/src/components/Autoform.jsx) or a HoC. 180 | 181 | Read the [documentation](#documentation) to find out what else you can do. 182 | 183 | #### 4. Translate 184 | 185 | You probably see labels like `models.client.name` instead of the proper ones. That's because the project uses a built-in translation system. You can setup those strings both at once or incrementally. 186 | 187 | Simple call `addTranslations` directly in your module or modules. Example: 188 | 189 | ```javascript 190 | import { addTranslations } from 'react-hook-form-auto' 191 | 192 | addTranslations({ 193 | models: { 194 | client: { 195 | name: 'Name', 196 | age: 'Age' 197 | } 198 | } 199 | }) 200 | ``` 201 | 202 | A simple way to fill this is by replicating the unstranslated string in the object. The dot is a subobject. In the former example you would see a label called `models.client.name`. 203 | 204 | ## Available skins 205 | 206 | Some of them need other imports. See instructions from each. 207 | 208 | ### [Vanilla](https://github.com/dgonz64/react-hook-form-auto) (here) 209 | ### Bootstrap 4 (as instructed in this document) 210 | ### [Material-UI](https://github.com/dgonz64/rhfa-material-ui) 211 | ### [Blueprint](https://github.com/dgonz64/rhfa-blueprint) 212 | ### [React Native](https://github.com/dgonz64/rhfa-react-native) 213 | 214 | ## Rationale 215 | 216 | One of the purposes of the library is to avoid repeating code by not having to write a set of input components for every entity. Also when time is of the essence, writing forms can be exasperating. 217 | 218 | These are some of the advantages of using an automatic form system. 219 | 220 | * More [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) 221 | * More [SSoT](https://en.wikipedia.org/wiki/Single_source_of_truth) 222 | * *...so less bugs* 223 | 224 | Also react-hook-form-auto has some of its own 225 | 226 | * Includes a translation system 227 | * It's easily expandable 228 | * It's simple 229 | * When possible tries to use [convention over configuration](https://en.wikipedia.org/wiki/Convention_over_configuration) 230 | 231 | # Roadmap 232 | 233 | Actually there aren't clearly defined goals. The library already suits my personal needs. Do you need anything that is not there? Feel free to write an issue! Those are the tasks I think may be interesting and will randomly work on them: 234 | 235 | - [x] Automatic form generation 236 | - [x] Able to stylize components 237 | - [x] Datatype coertion 238 | - [x] Provide more and better styling examples 239 | - [x] Styles to make it look like bootstrap4 240 | - [x] _Native_ components for famous ui libraries like bootstrap4 241 | - [ ] Need other? Open issue! 242 | - [x] Actually limit children 243 | - [x] React Native support 244 | - [x] Translated messages from server/async 245 | - [x] Make it compatible with `react-hook-form` 7. 246 | 247 | # Documentation 248 | 249 | ## Types 250 | 251 | Each schema can be regarded as a Rails model or a database table. You can store them anywhere, for example in a module: 252 | 253 | ```javascript 254 | import { createSchema } from 'react-hook-form-auto' 255 | 256 | export const computer = createSchema('computers', { /* ...schema... */ }) 257 | ``` 258 | 259 | Each first level entry in the schema object represents the fields in the form or the columns in the database analogy. To configure the field you use another object. Example: 260 | 261 | ```javascript 262 | { 263 | name: { type: 'string' } 264 | } 265 | ``` 266 | 267 | These are the types a field can be: 268 | 269 | | Type | Valid when... | Input control | 270 | | ---------- | ------------------------ | ----------------------------- | 271 | | string | Value is a string | `` | 272 | | number | Value is a number | `` | 273 | | range | Between two numbers | `` | 274 | | [\] | Each array item is valid | Renders as array | 275 | | \ | Value follows schema | Renders as submodel | 276 | | select | Value belongs to set | `` | 280 | 281 | You can specify the type as a constructor. There's not an easily measurable advantage. Example: 282 | 283 | ```javascript 284 | { type: String } 285 | ``` 286 | 287 | #### select and radios 288 | 289 | They both allow options as an array that can be one of strings with the options keys that will be feed to translator using `trModel()` or can be objects in the form `{ value, label }`. If an object is provided, label will be used for the HTML content (display) and value for the option's value. 290 | 291 | Options can also be a function. In that case it will be evaluated with the component props as the first argument. The results used as explained above, that is, array of strings or objects. 292 | 293 | Options as object 294 | 295 | Example with keys: 296 | 297 | ```javascript 298 | { 299 | type: 'select', 300 | options: ['red', 'blue'] 301 | } 302 | ``` 303 | 304 | Example with objects: 305 | 306 | ```javascript 307 | { 308 | type: 'select', 309 | options: [ 310 | { value: 'r', key: 'red' }, 311 | { value: 'b', key: 'blue' } 312 | ] 313 | } 314 | ``` 315 | 316 | You can specify label too. In that case it will be a direct already translated string. If you setup `key` then label will be infered and translated. 317 | 318 | Example with direct labels: 319 | 320 | ```javascript 321 | { 322 | type: 'select', 323 | options: [ 324 | { value: 'r', label: 'Some red' }, 325 | { value: 'b', label: 'Some blue' } 326 | ] 327 | } 328 | ``` 329 | 330 | Example with function. This example assumes `Autoform` component has a color collection in the props: 331 | 332 | ```javascript 333 | { 334 | type: 'select', 335 | options: props => props.colors.map(color => ({ 336 | value: color.id, 337 | key: color.name 338 | })) 339 | } 340 | ``` 341 | 342 | ### Validators 343 | 344 | | Validation | Type | Meaning | Error string ID | 345 | | ----------- | -------- | -------------------------------------------------- | --- | 346 | | minChildren | number | Minimum amount of children an array field can have | `error.minChildren` | 347 | | maxChildren | number | Maximum amount of children an array field can have | `error.maxChildren` | 348 | | min | number | Minimum value | `error.min` | 349 | | max | number | Maximum value | `error.min` | 350 | | required | boolean | The value can't be empty or undefined | `error.required` | 351 | | validate | function | Function that takes the value and entry and returns validity | | 352 | | pattern | regex | Regex. The value matches the regex | | 353 | 354 | The string returned will be translated. The translation will receive the field's schema as [variables](#variable-substitution). 355 | 356 | ### The `validate` validator 357 | 358 | The function used to validate the field, as any other validator, can return: 359 | 360 | * true to fail (and automatically generate message) or false to pass 361 | * An string that will be the error message 362 | 363 | ### Other schema fields 364 | 365 | There are some other attributes you can pass while defining the field schema: 366 | 367 | | Attribute | Type | Meaning | 368 | | ----------- | -------- | -------------------------------------------------- | 369 | | noAutocomplete | boolean | Inputs (or skin's `component`) will have `autocomplete=off` to help skip browser's autocomplete | 370 | | addWrapperProps | object | Directly passed to wrapper component | 371 | | addInputProps | object | Props merged into input component | 372 | | onChange | function | Allows you to change values on the fly. See [this](docs/schema-onchange.md). | 373 | | helperText | string | Explicit string for the helper text for the field, for example `tr('example')` | 374 | 375 | #### Helper text 376 | 377 | The `helperText` attribute in the schema allows you to set a fixed helper text for the field. The helper text can be changed in other ways and this is the precedence order: 378 | 379 | 1. Text set using `setHelperText` from schema (or `FieldPropsOverride`) `onChange`. 380 | 1. Explicit text set by schema `{ name: 'name', type: 'string', helperText: tr('example') }` 381 | 1. Automatic translation string in the form `models..._helper` 382 | 383 | ### Schema 384 | 385 | The schema establishes the validation and inputs. The instance can be stored anywhere, like your own model object or a module. At the moment it doesn't change over time. 386 | 387 | ### createSchema 388 | 389 | {{#function name="createSchema"~}} 390 | {{>body~}} 391 | {{/function}} 392 | 393 | ### Autoform component 394 | 395 | The `` component accepts the following props 396 | 397 | | Prop | Type | Meaning | 398 | | ----------- | --------------- | ------------------------------------------------------ | 399 | | schema | Schema instance | Schema used to build the form | 400 | | elementProps | object | Props extended in all the inputs | 401 | | initialValues | object | Initial values | 402 | | children | element | Whatever you want to put inside the form | 403 | | onSubmit | function | (optional) Code called when submitting with the coerced doc | 404 | | onChange | function | (optional) Code called after any change with the current coerced doc | 405 | | onErrors | function | (optional) Code called when form has errors | 406 | | forceErrors | object | (optional) Object with the errors or _falsy_ like `null` to not force. Keys will be the field name (example `pets.0.name`) and the value is another object with key `message`. Example: `{ username: { message: 'Taken' } }` | 407 | | styles | object | Styles used by the defaultSkin | 408 | | submitButton | boolean | (optional) Include submit button | 409 | | submitButtonText | element | (optional) Contents for the submit button | 410 | | skin | object | Base skin | 411 | | skinOverride | object | Override some skin components | 412 | | disableCoercing | boolean | Disable all coercing and get values as text | 413 | | noAutocomplete | boolean | Disable all autocompleting by passing `autocomplete=off` to the input or skin's `props` | 414 | 415 | Any other prop will be passed to the skin `props()`. 416 | 417 | ### Config 418 | 419 | The `config` prop is an object that has the following attributes 420 | 421 | | Attribute | Meaning | 422 | | ---------- | ------------------------------------------------------------------- | 423 | | arrayMode | `'table'` or `'panels'` depending on wanted array field format. `panels` uses card/box/panel wrapping for elements. `table` uses tables (might not fit but if it does is perfect for compact models) | 424 | 425 | ### Field props override 426 | 427 | You can override field props individually. You can do this with a component called `FieldPropsOverride`. This is useful when you want to create an special field with some functionality that forces you to provide an event handler. Let's see an example: 428 | 429 | ```javascript 430 | import { Autoform, FieldPropsOverride } from 'react-hook-form-auto' 431 | 432 | const Component = ({ onKeyDown }) => 433 | 434 | 438 | 439 | ``` 440 | 441 | The name can specified without taking into account array ordinals. For example, if a `pet` serves as an schema array for an `owner` and you want to override every pet name from `pets` field (array), you should use `pets.name` as the `name` prop: 442 | 443 | ```javascript 444 | 448 | ``` 449 | 450 | It can also be an specific path like `pets[0].name`. 451 | 452 | #### ``'s `onChange` 453 | 454 | In `FieldPropsOverride`, `onChange` prop is automatically chained with both ReactHookForm and schema `onChange`. The callback receives exactly [the same arguments as schema `onChange`](https://github.com/dgonz64/react-hook-form-auto/blob/master/docs/schema-onchange.md). The order of calling is: 455 | 456 | 1. ReactHookForm `onChange` 457 | 1. Schema's `onChange` 458 | 1. `FieldPropsOverride`'s `onChange` 459 | 460 | Example: 461 | 462 | ```javascript 463 | const handleChange = (value, { setValue }) => { 464 | if (value == 'tall') 465 | setValue('name', 'André the Giant') 466 | } 467 | 468 | return ( 469 | 470 | 474 | 475 | ) 476 | ``` 477 | 478 | ### Coercers 479 | 480 | While react-hook-form works with inputs, this library is focused in models. This means: 481 | 482 | * Values from inputs are coerced to the datatype you define in the schema. 483 | * Default values are left untouched if there they are not defined or registered. 484 | * You can manually use setValue from your skin or skinOverride. 485 | 486 | You can also disable coercers with the `Autoform`'s `disableCoercing` prop. 487 | 488 | ### Select 489 | 490 | Select will include an empty option for uninitialized elements. Please, write an issue if you want this to be configurable. 491 | 492 | ### Forcing errors 493 | 494 | You can force an error message for any field, including a nested one. For it you elaborate an object with the field keys in ReactHookForm notation, for example `pets.0.name`. Complete example: 495 | 496 | ```javascript 497 | const LoginContainer = () => { 498 | const [ serverErrors, setServerErrors ] = useState(null) 499 | 500 | const handleLogin = () => { 501 | setServerErrors({ 502 | username: { message: 'Already taken' }, 503 | password: { message: 'Also, wrong password' } 504 | }) 505 | } 506 | 507 | return ( 508 | 513 | ) 514 | } 515 | ``` 516 | 517 | Also take a look at this [demo](https://codesandbox.io/s/rhfa-emergency-example-server-validation-9571b7?file=/src/App.js). After you pass client validation (just fill both names), you can click Send to simulate server validation errors. 518 | 519 | ## Imperative handling 520 | 521 | `Autoform` component sets some functions to be used in referenced component: 522 | 523 | ```javascript 524 | let formRef 525 | 526 | // Example: imperative submit 527 | const doSubmit = () => { 528 | formRef.submit() 529 | } 530 | 531 | const MyForm = ({ onSubmit }) => 532 | formRef = e} 535 | /> 536 | ``` 537 | 538 | Functions returned: 539 | 540 | | Attribute | Description | 541 | | --- | --- | 542 | | submit() | Imperative submit. | 543 | | setValue(name, value, options) | Sets a value in any place of the document. You can use a path. As this library coerces values, it's better to use this than the one from react-hook-form to avoid inconsistences. The parameter `options` is passed to ReactHookForm | 544 | | setVisible(name, visible) | Sets the visibility for an element. | 545 | | reset() | Resets every field value to initial's. | 546 | | getValues() | Returns the coerced form values | 547 | | formHook() | Call this in order to get react-hook-form vanilla reference object. | 548 | 549 | ## Translation 550 | 551 | react-hook-form-auto uses internally a simple better-than-nothing built-in translation system. This system and its translation tables can be replaced and even completely overridden. 552 | 553 | The translation strings are hierarchized following some pseudo-semantic rules: 554 | 555 | ```javascript 556 | `models.${model}.${field}.${misc}` 557 | ``` 558 | 559 | The meaning of the last `misc` part usually depends on the type of field. 560 | 561 | The dots navigates through children in the string table. Example: 562 | 563 | ```javascript 564 | { 565 | models: { 566 | computer: { 567 | cpu: { 568 | arm: 'ARM', 569 | // ... 570 | }, 571 | // ... 572 | } 573 | } 574 | } 575 | ``` 576 | 577 | To translate string for your use call `tr()` and pass the string path separated by dots: 578 | 579 | ```javascript 580 | import { tr } from 'react-hook-form-auto 581 | 582 | const message = tr('models.computers.cpu.arm') 583 | 584 | /* message value is 'ARM' */ 585 | ``` 586 | 587 | This is the usage of the `tr()` function: 588 | 589 | ### tr 590 | {{#function name="tr"~}} 591 | {{>body~}} 592 | {{/function}} 593 | 594 | ### stringExists 595 | {{#function name="stringExists"~}} 596 | {{>body~}} 597 | {{/function}} 598 | 599 | ### Variable substitution 600 | 601 | You can also put variables in the translation strings: 602 | 603 | ```javascript 604 | { 605 | name: { 606 | create: '__name__ created' 607 | } 608 | } 609 | ``` 610 | 611 | This allows you to translate and inserting a value in the correct place of the string. Example: 612 | 613 | ```javascript 614 | import { tr } from 'react-hook-form-auto' 615 | 616 | const name = 'Alice' 617 | const message = tr('name.create', { name }) 618 | 619 | /* message value is 'Alice created' */ 620 | ``` 621 | 622 | ### Adding strings 623 | 624 | ```javascript 625 | import { addTranslations } from 'react-hook-form-auto' 626 | ``` 627 | 628 | Then you call addTranslations with the object tree: 629 | 630 | ### addTranslations 631 | {{#function name="addTranslations"~}} 632 | {{>body~}} 633 | {{/function}} 634 | 635 | It's ok to overwrite a language with another. The non translated strings from the new language will remain from the former in the table. At the moment it's up to you to take care about language completeness (or, better, use an external translator). 636 | 637 | ### Multilanguage 638 | 639 | If your application has more than one language and it can be changed on the fly, I better recommend to use translation utils over the core translations. Those functions store the languages separately: 640 | 641 | ```javascript 642 | import { setLanguageByName } from 'react-hook-form-auto' 643 | 644 | setLanguageByName('en') 645 | ``` 646 | 647 | You can add the translation strings directly to a language: 648 | 649 | ### addLanguageTranslations 650 | {{#function name="addLanguageTranslations"~}} 651 | {{>body~}} 652 | {{/function}} 653 | 654 | ### Translation utils 655 | 656 | There are some functions that deal with semantic organization of the translation strings this library uses. You can take advantage of them if you are building a skin: 657 | 658 | ### trModel 659 | {{#function name="trModel"~}} 660 | {{>body~}} 661 | {{/function}} 662 | 663 | ### trField 664 | {{#function name="trField"~}} 665 | {{>body~}} 666 | {{/function}} 667 | 668 | ### trError 669 | {{#function name="trError"~}} 670 | {{>body~}} 671 | {{/function}} 672 | 673 | ### Use your own translation system 674 | 675 | You can disable the translation system to use your own. 676 | 677 | ### setTranslator 678 | {{#function name="setTranslator"~}} 679 | {{>body~}} 680 | {{/function}} 681 | 682 | Example: 683 | 684 | ```javascript 685 | import { setTranslator } from 'react-hook-form-auto' 686 | import { myTranslationTranslator } from './serious_translator' 687 | 688 | setTranslator((tr, data) => { 689 | /* do something with tr or data */ 690 | 691 | return myTranslationTranslator(something, somethingElse) 692 | }) 693 | ``` 694 | 695 | Or you can drop it directly to `setTranslator()` if it's compatible. 696 | 697 | ## Extending 698 | 699 | If you just need another appearance, you can do it changing styles. If you are adapting an existing UI library (like [Blueprint](https://blueprintjs.com/)) then it's better to extend skin. 700 | 701 | ### With styles 702 | 703 | The default skin from `react-hook-form-auto` uses css classes. You can override them providing your set. Example with css-modules: 704 | 705 | ```javascript 706 | import styles from './my_styles.css' 707 | 708 | 709 | ``` 710 | 711 | The whole [rhfa-emergency-styles](https://github.com/dgonz64/rhfa-emergency-styles) does this and can serve as an example. 712 | 713 | ### Overriding skin 714 | 715 | When you need to adapt behaviour, styles might not be enough. To solve this you can override full components. 716 | 717 | The inputs and auxiliary elements are created using a set of components. The mapping can be set all together by overriding the skin, like this: 718 | 719 | ```javascript 720 | import { Autoform as RHFAutoform } from 'react-hook-form-auto' 721 | 722 | import overrides from './skinOverride' 723 | 724 | export const Autoform = (props) => 725 | 729 | ``` 730 | 731 | You can take a look at [defaultSkin.js](https://github.com/dgonz64/react-hook-form-auto/blob/master/src/ui/defaultSkin.jsx) and [`components/index.js`](https://github.com/dgonz64/react-hook-form-auto/tree/master/src/ui/components) from any skin to have a glimpse. 732 | 733 | Also find [here](https://github.com/dgonz64/rhfa-material-ui/blob/master/src/skinOverride.js#L56) a full skin override. 734 | 735 | I made a tutorial adapting Material-UI to react-hook-form-auto. You can find it [here](https://github.com/dgonz64/rhfa-playground). 736 | 737 | ### Extension process 738 | 739 | There's an entry in the skin object for every field type. The value of the entry is an object with two attributes like in this example: 740 | 741 | ```javascript 742 | number: { 743 | coerce: value => parseFloat(value), 744 | props: { 745 | component: 'input', 746 | type: 'number' 747 | } 748 | }, 749 | ``` 750 | 751 | #### coerce 752 | 753 | Function that converts result to its correct datatype. 754 | 755 | #### props 756 | 757 | Prop transformation for the rendered component. 758 | 759 | The attribute `component` is the React component used to render the input inside the wrappers. 760 | 761 | ```javascript 762 | select: { 763 | props: { 764 | component: Select 765 | } 766 | }, 767 | ``` 768 | 769 | It can be also a first level attribute, useful if you don't need to change props 770 | 771 | ```javascript 772 | select: { 773 | component: Select 774 | }, 775 | ``` 776 | 777 | ##### `props` is an object 778 | 779 | Props merged to component's default: 780 | 781 | ```javascript 782 | range: { 783 | coerce: value => parseFloat(value), 784 | props: { 785 | component: 'input', 786 | type: 'range' 787 | } 788 | }, 789 | ``` 790 | 791 | If `component` is a string, like in this example, it will only receive ``-like props, like `name` and `onChange`. 792 | 793 | ##### `props` is a function 794 | 795 | Function that takes the component's intended props and returns component's final props: 796 | 797 | ```javascript 798 | string: { 799 | props: props => ({ 800 | ...props, 801 | component: props.fieldSchema.textarea ? 'textarea' : 'input' 802 | }), 803 | }, 804 | ``` 805 | 806 | #### `onChange` and `onBlur` in props 807 | 808 | The `component` in the `props` property is also in charge of the field update: For that matter you have two options 809 | 810 | ##### Use it directly (recommended) 811 | 812 | ```javascript 813 | const Input = ({ name, onChange, onBlur }) => 814 | 815 | 816 | 817 | 818 | const mySkinOverride = { 819 | string: { 820 | component: Input 821 | } 822 | } 823 | ``` 824 | 825 | ##### Use `setValue` 826 | 827 | Another way is to use `setValue(name, newValue)` on every change. The component is still uncontrolled but model's value will be updated. 828 | 829 | ```javascript 830 | props: ({ name, register, setValue }) => { 831 | const setValueFromEvent = event => { 832 | setValue(name, event.target.value) 833 | } 834 | 835 | return { 836 | component: 837 | } 838 | } 839 | ``` 840 | 841 | ### Other skin block attributes 842 | 843 | ```javascript 844 | range: { 845 | coerce: value => parseFloat(value), 846 | controlled: true, 847 | skipRegister: true, 848 | nameForErrors: name => `${name}__counter`, 849 | props: { 850 | component: 'input', 851 | type: 'range' 852 | } 853 | }, 854 | ``` 855 | 856 | | Attribute | Means | 857 | | --------- | ----- | 858 | | `controlled` | Will `useController()`. Component will receive also `value` prop | 859 | | `skipRegister` | Will not automatically register this field. Hasn't any meaning if `controlled` is `true` | 860 | | `nameForErrors` | Function that receives `name` and returns a transformed name used to navigate through the `errors` object returned from `react-hook-form`'s `useFormState`. Helps to create errors for non registered field like `minChildren` does. | 861 | 862 | ## Overriding skin 863 | 864 | You can override (or add) specific types by using ``'s `skinOverride` prop. 865 | 866 | ### Avoid `array` and `schema` 867 | 868 | Only override `array` and `schema` types if you know `react-hook-form-auto` internals. For example both need `skipRegister`. 869 | 870 | ### `skin[type].props` 871 | 872 | The rest of the properties a skin block can override: 873 | 874 | | Prop | Type | Use | 875 | | ---------------- | ----------------- | ------------------------------------------ | 876 | | `component` | element | Component used to render the input | 877 | | `wrapper` | function | Override wrapper `skin.defaultWrap` | 878 | | `name` | string | (**Must not be changed**) Complete field name (with hierarchy) | 879 | | `field` | string | (**Shouldn't be changed**) This field name | 880 | | `option` | string | Forces value (used in radios for example ) | 881 | | `inline` | boolean | Goes to the wrapper as `inline` | 882 | | `styles` | object | Style overriding | 883 | | `fieldSchema` | object | Schema specification for the field | 884 | | `schemaTypeName` | string (required) | Name of the schema that contains the field | 885 | | `parent` | string | Name of the containing field | 886 | | `config` | object | Override config for this input | 887 | | `index` | number | Index for arrays | 888 | | `formHook` | object | ReacHookForm's register() returned object | 889 | | `autoformProps` | object | Properties passed to Autoform | 890 | | `skinElement` | object | skin resolver for the type of this field (for example `skin.boolean`) | 891 | 892 | ### `skin[type].component` or `skin[type].props.component` 893 | 894 | The value of this field is used to render the input component. If it's a component, the will receive these props, appart from the rest of the `skin[type].props` block. 895 | 896 | If `component` is specified both from `props` and directly (`skin[type].component`), the one coming from props will have priority. 897 | 898 | | Prop | Type | Use | 899 | | ---------------- | -------- | --------------------------------------------------- | 900 | | `id` | string | Id usable to match label in dom | 901 | | `name` | string | Full path name for the `` | 902 | | `type` | string | `type` as would be passed to `` | 903 | | `defaultValue` | any | As calculated from initialValues, schema and skin | 904 | | `onChange` | function | Should be called back with dom `change` event handler | 905 | | `onBlur` | function | Callback for dom `blur` | 906 | | `className` | function | Calculated classes | 907 | | `field` | function | Relative field name | 908 | | `inputRef` | function | ref for the input element. Comes from `register` and it's used to update fields imperatively | 909 | | `errorText` | string | Validation errors or `''` | 910 | | `fieldSchema` | object | Part of the schema that refers to this field | 911 | | `formHook` | object | As returned from the `useForm()` call | 912 | | `styles` | object | All the styles in case you do class styling | 913 | | `skinElement` | object | Complete skin block specific to this type of field (for example `skin.number`) | 914 | | `setVisible` | function | Changes visibility of fields `setVisible(name, visible)`. If the field is not visible, then it will not appear in submit doc neither. | 915 | | `setHelperText` | function | Changes field helper text `setHelperText(name, text)` | 916 | | `setValue` | function | Changes value of any field `setValue(name, value)` | 917 | | `arrayControl` | object | Some control functions in the case field is inside an array. See [this](doc/schema-onchange.md#arraycontrol) | 918 | | `value` | any | Only for controlled types (`skin[type].controlled == true`) | 919 | | ... | any | ...any other prop added in schema with `addInputProps` | 920 | 921 | ### `InputWrap` 922 | 923 | It's a common component used to create all the input structure, including wrappers. You can use it to avoid boilerplate. 924 | 925 | | Prop | Type | Use | 926 | | ---------------- | -------- | --------------------------------------------------- | 927 | | `children` | element | (optional) You can use InputWrap to... wrap. | 928 | | `id` | string | id for the `for` attribute of the label | 929 | | `name` | string | Full name (with hierarchy) | 930 | | `schemaTypeName` | string | Name of the schema as created | 931 | | `styles` | object | Styles | 932 | | `labelOverride` | string | Label overriding | 933 | | `inline` | boolean | Will omit label | 934 | | `errorText` | string | Validation error | 935 | 936 | The props for the input component are different depending if the `inputComponent` is a string, like `'input'` or a React component. 937 | 938 | #### Native component (ie. `'input'`) 939 | 940 | For a 'lowercase' component like ``, props passed are 941 | 942 | * id 943 | * name 944 | * type 945 | * option (if provided, for the value) 946 | * defaultValue 947 | * onChange 948 | * onBlur 949 | * ref 950 | * className calculated 951 | * ...any fields added to schema's `addInputProps` 952 | 953 | #### Class component (ie. `MySlider`) 954 | 955 | Class components receive all the provided or derived props. 956 | 957 | ### Rendering 958 | 959 | To render the input or inputs, two functions are used: 960 | 961 | ### renderInputs 962 | {{#function name="renderInputs"~}} 963 | {{>body~}} 964 | {{/function}} 965 | 966 | ### renderInput 967 | {{#function name="renderInput"~}} 968 | {{>body~}} 969 | {{/function}} 970 | 971 | ## Importing base 972 | 973 | You can use the base bundle to import `AutoformBase` component. `AutoformBase` is like `Autoform` but doesn't use `react-dom` dependency. 974 | 975 | ```javascript 976 | import { Autoform, ... } from 'react-hook-form-auto/dist/base.js' 977 | ``` 978 | 979 | You can also do it in a more permanent way if you use webpack: 980 | 981 | ```javascript 982 | resolve: { 983 | alias: { 984 | 'react-hook-form-auto': 'react-hook-form-auto/dist/base.js' 985 | } 986 | }, 987 | ``` 988 | 989 | # Help wanted / contribute 990 | 991 | react-hook-form-auto needs your help. Skins must be written! 992 | 993 | Also, it would make my day to see the library working in your project, provided it's public. Please, tell me! 994 | 995 | ### Where to begin 996 | 997 | Make a fork and do your magic followed by a [pull request](https://help.github.com/articles/about-pull-requests/). If it's an implementation, test case and documentation update would be highly appreciated. 998 | 999 | ### Some code pointers 1000 | 1001 | Everything is work in progress until there's a bunch of skins or we are in complete control of the world, whichever comes first. 1002 | 1003 | The most important files: 1004 | 1005 | * `src/ui/components/componentRender.jsx` processes the schema to build the inputs and allows skin overrides 1006 | * `src/ui/Autoform.jsx` The component and the coercing logic 1007 | * `src/ui/AutofieldContainer.jsx` Registers or calls `useController()` 1008 | * `src/ui/Autofield.jsx` Wraps component within wrapper 1009 | * `jsdoc2md/README.hbs` Documentation source 1010 | 1011 | See also the [contributing doc](https://github.com/dgonz64/react-hook-form-auto/blob/master/CONTRIBUTING.md). 1012 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hook-form-auto", 3 | "version": "1.3.15", 4 | "description": "Generate automatic forms following a schema", 5 | "main": "lib/index.js", 6 | "keywords": [ 7 | "react", 8 | "reactjs", 9 | "hooks", 10 | "react-hook-form", 11 | "form", 12 | "validators", 13 | "validation" 14 | ], 15 | "author": "David González ", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/dgonz64/react-hook-form-auto" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "^7.24.6", 23 | "@babel/core": "^7.24.6", 24 | "@babel/plugin-transform-class-properties": "^7.24.6", 25 | "@babel/plugin-transform-object-rest-spread": "^7.24.6", 26 | "@babel/preset-env": "^7.24.6", 27 | "@babel/preset-react": "^7.24.6", 28 | "@wojtekmaj/enzyme-adapter-react-17": "^0.8.0", 29 | "babel-loader": "^9.1.3", 30 | "enzyme": "^3.11.0", 31 | "jest": "^27.0.0", 32 | "jsdoc-to-markdown": "^8.0.1", 33 | "jsdom": "19.0.0", 34 | "jsdom-global": "3.0.2", 35 | "prop-types": "^15.8.1", 36 | "react": "^17", 37 | "react-dom": "^17.0.0", 38 | "react-hook-form": "^7.51.5", 39 | "rimraf": "^5.0.7", 40 | "webpack": "^5.91.0", 41 | "webpack-cli": "^5.1.4", 42 | "webpack-merge": "^5.10.0" 43 | }, 44 | "peerDependencies": { 45 | "react": "^16.13.1 || ^17 || ^18", 46 | "react-hook-form": "^7" 47 | }, 48 | "dependencies": { 49 | "classnames": "^2.5.1" 50 | }, 51 | "scripts": { 52 | "clean": "rimraf dist && rimraf lib", 53 | "start": "npm run clean && babel src --out-dir lib --watch --verbose --source-maps", 54 | "build": "npm run clean && npm run build:commonjs && npm run build:umd && npm run build:umd:min && npm run build:base && npm run build:base:min", 55 | "build:commonjs": "babel src --out-dir lib", 56 | "build:umd": "webpack --env mode=production --env minify=false", 57 | "build:umd:min": "webpack --env mode=production --env minify=true", 58 | "build:base": "webpack --env mode=development --env minify=false --env buildtype=base", 59 | "build:base:min": "webpack --env mode=production --env minify=true --env buildtype=base", 60 | "watch:base": "webpack --env mode=development --env minify=false --env buildtype=base --watch", 61 | "docs": "jsdoc2md -t jsdoc2md/README.hbs src/*.js src/**/*.js src/**/*.jsx > README.md; echo ", 62 | "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand", 63 | "test:short": "jest", 64 | "test": "jest --verbose", 65 | "prepublishOnly": "npm run build" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/autoform_state.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react' 2 | import { deepmerge } from './utils' 3 | 4 | import { 5 | valueOrCreate, 6 | objectTraverse 7 | } from './utils' 8 | import { createCoercers } from './coercing' 9 | import { PubSub } from './pubsub' 10 | 11 | export const useAutoformState = ({ 12 | initialValues, 13 | onSubmit, 14 | onChange, 15 | schema, 16 | skin, 17 | formHook, 18 | skipManualReset 19 | }) => { 20 | const stateRef = useRef({ 21 | stateControl: new PubSub(), 22 | fields: {} 23 | }) 24 | 25 | const { stateControl } = stateRef.current 26 | 27 | const coercersBase = { 28 | initialValues, 29 | stateRef, 30 | skin, 31 | onSubmit, 32 | schema 33 | } 34 | 35 | const coercedSubmit = createCoercers({ 36 | ...coercersBase, 37 | notify: onSubmit 38 | }) 39 | 40 | let coercedChange 41 | if (onChange) { 42 | const coercedChangeDoc = onChange && createCoercers({ 43 | ...coercersBase, 44 | notify: onChange 45 | }) 46 | 47 | coercedChange = () => { 48 | const doc = formHook.getValues() 49 | return coercedChangeDoc(doc) 50 | } 51 | } else { 52 | coercedChange = null 53 | } 54 | 55 | const schemaDef = schema.getSchema() 56 | const findOrInitState = (name) => { 57 | return valueOrCreate(stateRef.current.fields, name, () => { 58 | const nameForVisible = `${name}.initiallyVisible` 59 | const initiallyVisible = objectTraverse(schemaDef, nameForVisible, { 60 | returnValue: true 61 | }) 62 | 63 | return { 64 | visible: initiallyVisible === null ? true : initiallyVisible, 65 | helperText: null 66 | } 67 | }) 68 | } 69 | 70 | const setValue = (name, value, options = {}) => { 71 | const fieldState = findOrInitState(name) 72 | 73 | const newState = { 74 | ...fieldState, 75 | value, 76 | changed: true 77 | } 78 | 79 | if (!options.skipSetInput) 80 | formHook.setValue(name, value, options) 81 | } 82 | 83 | stateControl.findOrInitState = findOrInitState 84 | stateControl.setValue = setValue 85 | 86 | /** 87 | * Sets values in the stateRef. Doesn't trigger. 88 | */ 89 | const setValues = (values, { parent = null, field }) => { 90 | const fields = Object.keys(values) 91 | fields.forEach(field => { 92 | const cur = values[field] 93 | if (typeof cur == 'object') { 94 | return setValues(cur, { parent: field, field }) 95 | } else { 96 | const name = inputName({ parent, field }) 97 | findOrInitState(fieldName) 98 | } 99 | }) 100 | } 101 | 102 | const resetState = (values, omit) => { 103 | stateRef.current.fields = {} 104 | 105 | setValues(values || {}, {}) 106 | 107 | const currentValues = formHook.getValues() 108 | 109 | if (!skipManualReset) { 110 | // Reset by setting everything to initialValues or null. 111 | function resetValues(obj, initials = {}, path = '', isArray = false) { 112 | const fields = Object.keys(obj) 113 | fields.forEach((field, idx) => { 114 | const value = obj[field] 115 | const elPath = isArray ? 116 | `${path}.${idx}` : (path ? `${path}.${field}` : field) 117 | const initial = initials[field] 118 | if (typeof value == 'object') 119 | resetValues(value, initial, elPath, Array.isArray(value)) 120 | else { 121 | const initialOrNull = typeof initial == 'undefined' ? null : initial 122 | formHook.setValue(elPath, initialOrNull) 123 | } 124 | }) 125 | } 126 | 127 | resetValues(currentValues, initialValues) 128 | } 129 | 130 | formHook.reset(values, omit) 131 | } 132 | 133 | const changeAndPublish = (name, attr, value) => { 134 | const state = findOrInitState(name) 135 | 136 | stateControl.publish(name, { 137 | ...state, 138 | [attr]: value 139 | }) 140 | 141 | state[attr] = value 142 | } 143 | 144 | const setVisible = (name, visible) => { 145 | changeAndPublish(name, 'visible', visible) 146 | } 147 | 148 | const setHelperText = (name, text) => { 149 | changeAndPublish(name, 'helperText', text) 150 | } 151 | 152 | const getValues = () => { 153 | let values = {} 154 | 155 | const coercedGetValues = createCoercers({ 156 | ...coercersBase, 157 | notify: (coerced) => { 158 | deepmerge(values, coerced) 159 | } 160 | }) 161 | 162 | const doc = formHook.getValues() 163 | coercedGetValues(doc) 164 | 165 | return values 166 | } 167 | 168 | return { 169 | coercedSubmit, 170 | coercedChange, 171 | setValue, 172 | setVisible, 173 | setHelperText, 174 | resetState, 175 | stateControl, 176 | getValues 177 | } 178 | } 179 | 180 | // Subscribes to visible, helperText and potential future additions 181 | export const useAutofieldState = ({ name, stateControl }) => { 182 | const initialState = stateControl.findOrInitState(name) 183 | const [ state, setState ] = useState({ ...initialState }) 184 | 185 | useEffect(() => { 186 | stateControl.subscribe(name, setState) 187 | return () => { 188 | stateControl.unsubscribe(name, setState) 189 | } 190 | }, []) 191 | 192 | return state 193 | } 194 | -------------------------------------------------------------------------------- /src/coercing.js: -------------------------------------------------------------------------------- 1 | import { 2 | deepmerge, 3 | schemaTypeEx, 4 | objectTraverse 5 | } from './utils' 6 | 7 | export function createCoercers({ 8 | initialValues, 9 | stateRef, 10 | skin, 11 | notify, 12 | schema 13 | }) { 14 | return function coercedSubmit(doc) { 15 | const coerceObject = ({ object, schemaDef }) => { 16 | const fields = Object.keys(schemaDef) 17 | const result = deepmerge({}, object) 18 | 19 | fields.forEach(fieldName => { 20 | const fieldSchema = schemaDef[fieldName] 21 | const { type } = fieldSchema 22 | const typeKey = schemaTypeEx(type) 23 | const { coerce } = fieldSchema.coerce ? 24 | fieldSchema : skin[typeKey] 25 | const value = object[fieldName] 26 | 27 | if (coerce) { 28 | result[fieldName] = coerce(value, { 29 | coerceObject, 30 | schemaDef, 31 | fieldName 32 | }) 33 | } 34 | }) 35 | 36 | return result 37 | } 38 | 39 | const coerceWithSchema = ({ doc, schema }) => { 40 | const schemaDef = schema.getSchema() 41 | 42 | return coerceObject({ 43 | object: doc, 44 | schemaDef 45 | }) 46 | } 47 | 48 | const fields = Object.keys(stateRef.current.fields) 49 | const values = fields.reduce((values, field) => { 50 | const state = stateRef.current.fields[field] 51 | 52 | if (state.visible) { 53 | const [ container, attr ] = objectTraverse(values, field, { 54 | createIfMissing: true 55 | }) 56 | const [ docContainer ] = objectTraverse(doc, field) 57 | if (container && attr) { 58 | if (state.changed) 59 | container[attr] = state.value 60 | else if (docContainer) 61 | container[attr] = docContainer[attr] 62 | } 63 | } 64 | 65 | return values 66 | }, {}) 67 | 68 | const wholeObj = deepmerge({}, initialValues, values) 69 | const coerced = coerceWithSchema({ doc: wholeObj, schema }) 70 | 71 | notify(coerced, doc) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/createSchema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a Schema from the specification. 3 | * 4 | * @function 5 | * @param {string} typeName Name of the model being created. 6 | * It can be chosen freely. 7 | * @param {object} schema Schema specification. 8 | */ 9 | export function createSchema(typeName, schema) { 10 | return { 11 | _type: 'schema', 12 | 13 | /** 14 | * Returns the schema specification. 15 | * 16 | * @returns {object} Schema specification. 17 | */ 18 | getSchema: () => schema, 19 | 20 | /** 21 | * Returns the schema specification. 22 | * 23 | * @returns {object} Schema specification. 24 | */ 25 | getFieldSchema: (name) => schema[name], 26 | 27 | /** 28 | * Returns the schema name. 29 | * 30 | * @returns {string} Schema name (also called ``typeName``). 31 | */ 32 | getType: () => typeName, 33 | 34 | /** 35 | * Returns the name of the fields. 36 | */ 37 | getFieldNames: () => Object.keys(schema) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './index_base' 2 | export { Autoform } from './ui/Autoform' 3 | 4 | export { Panel, Button } from './ui/components' 5 | export { FieldPropsOverride } from './ui/components/FieldPropsOverride' 6 | export { InputWrap } from './ui/components/InputWrap' 7 | -------------------------------------------------------------------------------- /src/index_base.js: -------------------------------------------------------------------------------- 1 | export { 2 | addTranslations, 3 | setTranslator, 4 | tr, 5 | stringExists, 6 | setTranslateVariableRegex, 7 | setTranslateReferenceRegex, 8 | } from './translate' 9 | export { 10 | processOptions, 11 | objectTraverse 12 | } from './utils' 13 | export { 14 | setLanguageByName, 15 | addLanguageTranslations, 16 | trModel, 17 | trField, 18 | trError, 19 | trPath, 20 | trPathSetBase 21 | } from './translation_utils' 22 | export { createSchema } from './createSchema' 23 | export { AutoformBase } from './ui/AutoformBase' 24 | export * from './ui/componentRender' 25 | -------------------------------------------------------------------------------- /src/pubsub.js: -------------------------------------------------------------------------------- 1 | import { valueOrCreate } from './utils' 2 | 3 | export class PubSub { 4 | constructor() { 5 | this.handlers = {} 6 | } 7 | 8 | subscribe(name, callback) { 9 | const handlers = valueOrCreate(this.handlers, name, () => []) 10 | const formerIndex = handlers.indexOf(callback) 11 | if (formerIndex == -1) 12 | handlers.push(callback) 13 | } 14 | 15 | unsubscribe(name, callback) { 16 | const handlers = this.handlers[name] 17 | if (handlers) { 18 | const index = handlers.indexOf(callback) 19 | if (index != -1) 20 | handlers.splice(index, 1) 21 | } 22 | } 23 | 24 | publish(name, data) { 25 | const handlers = this.handlers[name] 26 | if (handlers) { 27 | handlers.forEach(handler => { 28 | handler(data) 29 | }) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/translate.js: -------------------------------------------------------------------------------- 1 | import { deepmerge } from './utils.js' 2 | 3 | let translations = {} 4 | 5 | let varRegex = /__(.*?)__/g 6 | let refRegex = /@@(.*?)@@/g 7 | 8 | function findString(id = '') { 9 | const part = id.split('.') 10 | 11 | const lastIndex = part.length - 1 12 | return part.reduce((nodeInfo, cur, index) => { 13 | const { node, found } = nodeInfo 14 | const isLast = index == lastIndex 15 | const isString = typeof node == 'string' 16 | 17 | if (isString) { 18 | return { 19 | found, 20 | node 21 | } 22 | } else { 23 | if (node && node[cur]) { 24 | if (isLast && node[cur]._) { 25 | return { 26 | found: true, 27 | node: node[cur]._ 28 | } 29 | } else { 30 | return { 31 | found: isLast, 32 | node: node[cur] 33 | } 34 | } 35 | } else { 36 | return { 37 | found: node && '_' in node, 38 | node: node && node._ 39 | } 40 | } 41 | } 42 | }, { node: translations }) 43 | } 44 | 45 | function regexReplace(regex, str, callback) { 46 | let match 47 | let result = str 48 | 49 | const re = new RegExp(regex) 50 | while ((match = re.exec(str)) !== null) { 51 | const value = callback(match[1]) 52 | if (typeof value != 'undefined') 53 | result = result.replace(match[0], value) 54 | } 55 | 56 | return result 57 | } 58 | 59 | /** 60 | * Translates a string given its id. 61 | * 62 | * @param {string} id Identifier in the form 63 | * `key1.key2.key3` 64 | * @param {object} vars Object with substitution variables. It will 65 | * substitute ocurrences when string contains this expression: 66 | * `__variable__`. For example the string `"My name is __name__"` with 67 | * `vars = { name: 'David' }` will return `"My name is David"`. 68 | * 69 | * Keys will be searched by partitioning the path. 70 | * 71 | * It will get the latest found key if any. For example, given the 72 | * strings `{ "a": { "b": 'Hello' } }` and looking for `'a.b.c'` it will 73 | * return `'a.b'` (`"Hello"`). 74 | * @returns Translated string 75 | */ 76 | export function tr(id, vars = {}) { 77 | let { node } = findString(id) 78 | if (node) { 79 | // Find variables 80 | node = regexReplace(varRegex, node, match => vars[match]) 81 | 82 | // Find references 83 | node = regexReplace(refRegex, node, match => tr(match, vars)) 84 | 85 | return node 86 | } else 87 | return id 88 | } 89 | 90 | /** 91 | * Returns if the string does exist 92 | * 93 | * @param {string} id Identifier 94 | * 95 | * @returns { boolean } true if it exists 96 | */ 97 | export function stringExists(id) { 98 | const { found } = findString(id) 99 | return found 100 | } 101 | 102 | /** 103 | * Sets the language. 104 | * 105 | * At the moment this does the same as addTranslations. The 106 | * reason is not to lose translations reference until a better 107 | * way is figured out. 108 | * 109 | * @param {lang} Translations object with the format 110 | * { key: { _: 'Some string', inner: 'Some other string' } } 111 | * Then, we have the following paths 112 | * - key -> 'Some string' 113 | * - key.inner -> 'Some other string' 114 | */ 115 | export function setLanguage(lang) { 116 | addTranslations(lang) 117 | } 118 | 119 | /** 120 | * Appends translations to current translation table 121 | * 122 | * @param {object} lang Translations merged into current. 123 | */ 124 | export function addTranslations(lang) { 125 | translations = deepmerge(translations, lang) 126 | } 127 | 128 | /** 129 | * Sets the translation engine that responds to tr(). 130 | * 131 | * @param {function} translate Function with signature 132 | * translate(id, params). 133 | */ 134 | export function setTranslator(translate) { 135 | tr = translate 136 | } 137 | 138 | /** 139 | * Sets the regex for the variables 140 | */ 141 | export function setTranslateVariableRegex(newVarRegex) { 142 | varRegex = newVarRegex 143 | } 144 | 145 | /** 146 | * Sets the regex for the substitutions 147 | */ 148 | export function setTranslateReferenceRegex(newRefRegex) { 149 | refRegex = newRefRegex 150 | } 151 | -------------------------------------------------------------------------------- /src/translation_utils.js: -------------------------------------------------------------------------------- 1 | import { en, es } from './translations' 2 | import { deepmerge } from './utils.js' 3 | import { 4 | tr, 5 | setLanguage 6 | } from './translate' 7 | 8 | export { tr, setLanguage } 9 | 10 | const defLangs = { en, es } 11 | 12 | let modelBasePath = 'models' 13 | 14 | /** 15 | * Loads a language from the languages table. 16 | * 17 | * @param {string} name Language code as in `'en'` or `'fr'`. 18 | */ 19 | export function setLanguageByName(name) { 20 | if (name in defLangs) 21 | setLanguage(defLangs[name]) 22 | } 23 | 24 | /** 25 | * Allows to add a bunch of strings to a language 26 | */ 27 | export function addLanguageTranslations(lang, strings) { 28 | defLangs[lang] = deepmerge(defLangs[lang], strings) 29 | } 30 | 31 | /** 32 | * Multipurpose semantic-ish translation. 33 | * 34 | * @param {string} modelName Object name, usually what 35 | * you pass as the first parameter when you create 36 | * the schema. 37 | * @param {string} field Field name 38 | * @param {string} op Thing that varies based on 39 | * the type. 40 | */ 41 | export function trModel(modelName, field, op) { 42 | return tr(trPath(modelName, field, op)) 43 | } 44 | 45 | /** 46 | * Translate field name 47 | * 48 | * @param {string|object} modelName Object name, usually what 49 | * you pass as the first parameter when you create 50 | * the schema. It can also be an object with component 51 | * props so it will figure out the values 52 | * @param {string} field Field name 53 | */ 54 | export function trField(modelName, field) { 55 | if (typeof modelName == 'object') { 56 | field = modelName.field 57 | modelName = modelName.schemaTypeName 58 | } 59 | 60 | return tr(trPath(modelName, field, '_field')) 61 | } 62 | 63 | /** 64 | * Translates error message. 65 | * 66 | * @param {string} error Code of the error (usually the 67 | * validation code-name) 68 | * @param {object} data Field configuration from `createSchema()`. 69 | */ 70 | export function trError(error, data) { 71 | return tr(`error.${error}`, data) 72 | } 73 | 74 | /** 75 | * Generates a model translation path. 76 | * 77 | * @param {string} model Name of the model (ie: 'client') 78 | * @param {string} field Name of the field 79 | * @param {string} op Name of the option or any subthing. 80 | * 81 | * @returns {string} id for the translation string 82 | */ 83 | export function trPath(model, field, op) { 84 | if (typeof op == 'undefined') 85 | return [modelBasePath, model, field].join('.') 86 | else 87 | return [modelBasePath, model, field, op].join('.') 88 | } 89 | 90 | /** 91 | * Sets the base for the semantich(ish) translation, so 92 | * instead of 'models..' can be 93 | * 'my.base..' 94 | * 95 | * @param {string} newBasePath New path prepended to all 96 | * string paths. 97 | */ 98 | export function trPathSetBase(newBasePath) { 99 | modelBasePath = newBasePath 100 | } 101 | -------------------------------------------------------------------------------- /src/translations/en.js: -------------------------------------------------------------------------------- 1 | export const en = { 2 | add: 'Add', 3 | remove: 'Remove', 4 | error: { 5 | _: 'Error', 6 | type: 'Incorrect value, expecting a __type__', 7 | min: 'Value must be more than __min__', 8 | max: 'Value must be less than __max__', 9 | minLength: 'Value must be more than __minLength__ characters long', 10 | maxLength: 'Value must be less than __maxLength__ characters long', 11 | required: 'Required', 12 | minChildren: 'Expected to have at least __minChildren__', 13 | maxChildren: 'Can only have __maxChildren__', 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/translations/es.js: -------------------------------------------------------------------------------- 1 | export const es = { 2 | add: 'Añadir', 3 | remove: 'Quitar', 4 | error: { 5 | _: 'Error', 6 | type: 'Valor incorrecto, esperando __type__', 7 | min: 'El valor debe ser superior a __min__', 8 | max: 'El valor debe ser inferior a __max__', 9 | minLength: 'El valor debe tener como mínimo __minLength__ caracteres', 10 | maxLength: 'El valor debe tener como máximo __maxLength__ caracteres', 11 | required: 'Requerido', 12 | minChildren: 'Se esperan __minChildren__', 13 | maxChildren: 'Sólo puede haber __maxChildren__', 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/translations/index.js: -------------------------------------------------------------------------------- 1 | export * from './en' 2 | export * from './es' 3 | -------------------------------------------------------------------------------- /src/ui/Autofield.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useFormState } from 'react-hook-form' 3 | import { objectTraverse } from '../utils' 4 | import { tr, stringExists } from '../translate' 5 | import { trPath } from '../translation_utils' 6 | import classnames from 'classnames' 7 | 8 | export const Autofield = (props) => { 9 | const { 10 | id, 11 | name, 12 | wrapper = props.skin.defaultWrap, 13 | component, 14 | field, 15 | formHook: { control }, 16 | formHook, 17 | defaultValue, 18 | fieldSchema, 19 | helperText, 20 | inputRef, 21 | forceErrors, 22 | type, 23 | option, 24 | inline, 25 | styles, 26 | skinElement, 27 | noRef, 28 | noAutocomplete, 29 | onChange, 30 | onBlur, 31 | ...rest 32 | } = props 33 | 34 | const nameForErrors = skinElement.nameForErrors ? 35 | skinElement.nameForErrors(name) : name 36 | 37 | const { errors } = useFormState({ control, name: nameForErrors }) 38 | const fieldErrors = forceErrors && forceErrors[nameForErrors] 39 | || objectTraverse(errors, nameForErrors, { returnValue: true }) 40 | const errorText = fieldErrors && fieldErrors.message 41 | 42 | const actualKey = option ? `${name}.${option}` : name 43 | const $wrapper = wrapper 44 | const $component = component 45 | const isComponent = typeof component != 'string' 46 | let componentBaseProps = { 47 | id, 48 | key: actualKey, 49 | name, 50 | type, 51 | defaultValue, 52 | onChange, 53 | onBlur, 54 | className: classnames(styles.input, styles.standard, { 55 | [styles.errored]: fieldErrors 56 | }), 57 | ...fieldSchema.addInputProps 58 | } 59 | 60 | if (option) 61 | componentBaseProps.value = option 62 | 63 | let finalHelperText = helperText || fieldSchema.helperText 64 | if (!finalHelperText) { 65 | const helperId = trPath(props.schemaTypeName, field, '_helper') 66 | if (stringExists(helperId)) 67 | finalHelperText = tr(helperId) 68 | } 69 | 70 | let componentProps 71 | if (isComponent) { 72 | componentProps = { 73 | ...rest, 74 | ...componentBaseProps, 75 | field, 76 | forceErrors, 77 | errorText, 78 | fieldSchema, 79 | formHook, 80 | styles, 81 | skinElement, 82 | inputRef, 83 | helperText: finalHelperText 84 | } 85 | } else { 86 | componentProps = { 87 | ...componentBaseProps, 88 | ref: inputRef 89 | } 90 | } 91 | 92 | if (noAutocomplete || fieldSchema.noAutocomplete) 93 | componentProps.autoComplete = 'off' 94 | 95 | return ( 96 | <$wrapper 97 | {...rest} 98 | id={id} 99 | key={actualKey} 100 | name={name} 101 | field={field} 102 | styles={styles} 103 | fieldSchema={fieldSchema} 104 | errorText={errorText} 105 | helperText={finalHelperText} 106 | inline={inline} 107 | addWrapperProps={fieldSchema.addWrapperProps} 108 | > 109 | <$component 110 | {...componentProps} 111 | key={componentProps.key} 112 | /> 113 | 114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src/ui/AutofieldContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useController } from 'react-hook-form' 3 | import { Autofield } from './Autofield' 4 | import { 5 | objectTraverse, 6 | valueFromEvent, 7 | getPropsTransform 8 | } from '../utils' 9 | import { useAutofieldState } from '../autoform_state' 10 | 11 | export const AutofieldContainer = (props) => { 12 | const { 13 | id, 14 | name, 15 | fieldSchema, 16 | defaultValue, 17 | schemaTypeName, 18 | skinElement, 19 | formHook: { control }, 20 | formHook, 21 | register, 22 | rules, 23 | overrides, 24 | skin, 25 | stateControl, 26 | setVisible, 27 | setHelperText, 28 | setValue, 29 | arrayControl 30 | } = props 31 | 32 | const { 33 | visible, 34 | helperText 35 | } = useAutofieldState({ name, stateControl }) 36 | 37 | let baseProps = Object.assign({}, props, { helperText }) 38 | 39 | const { controlled } = skinElement 40 | 41 | if (controlled) { 42 | const { field } = useController({ 43 | name, 44 | control: control, 45 | rules 46 | }) 47 | 48 | baseProps.onChange = field.onChange 49 | baseProps.onBlur = field.onBlur 50 | baseProps.value = field.value 51 | } else { 52 | if (!skinElement.skipRegister) { 53 | const registerProps = register(name, rules) 54 | if (registerProps) { 55 | baseProps.onBlur = registerProps.onBlur 56 | baseProps.onChange = registerProps.onChange 57 | baseProps.inputRef = registerProps.ref 58 | } 59 | } 60 | } 61 | 62 | // Allow field schema or overrides onChange 63 | if ('onChange' in fieldSchema || 'onChange' in overrides) { 64 | const baseOnChange = baseProps.onChange 65 | const overrideOnChange = overrides.onChange 66 | if (overrideOnChange) 67 | delete overrides.onChange 68 | 69 | const onChangeArguments = { 70 | name, 71 | setVisible, 72 | setHelperText, 73 | formHook, 74 | setValue, 75 | arrayControl 76 | } 77 | 78 | const fireOnChange = (value) => { 79 | if (fieldSchema.onChange) 80 | fieldSchema.onChange(value, onChangeArguments) 81 | if (overrideOnChange) 82 | overrideOnChange(value, onChangeArguments) 83 | } 84 | 85 | baseProps.onChange = (event) => { 86 | const value = valueFromEvent(event) 87 | baseOnChange(event) 88 | fireOnChange(value) 89 | } 90 | 91 | baseProps.setValue = (name, value) => { 92 | setValue(name, value) 93 | fireOnChange(value) 94 | } 95 | } 96 | 97 | // Allow general onChange, passed to 98 | if (props.onChange) { 99 | const oldOnChange = baseProps.onChange 100 | baseProps.onChange = (event) => { 101 | oldOnChange(event) 102 | props.onChange() 103 | } 104 | } 105 | 106 | const propsTransform = getPropsTransform(skinElement) 107 | 108 | let transformedProps 109 | if (typeof propsTransform == 'function') 110 | transformedProps = propsTransform ? propsTransform(baseProps) : baseProps 111 | else 112 | transformedProps = { ...baseProps, ...propsTransform } 113 | transformedProps = { ...transformedProps, ...overrides } 114 | 115 | const component = transformedProps.component || skinElement.component 116 | if (visible && component) { 117 | return ( 118 | 122 | ) 123 | } else { 124 | return null 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ui/Autoform.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | import defaultSkin from './defaultSkin' 3 | import { AutoformBase } from './AutoformBase' 4 | 5 | /** 6 | * Creates a form using the current skin. The form 7 | * has all the needed fields, styles and validation 8 | * errors in order to work. 9 | */ 10 | export let Autoform = (props, ref) => { 11 | const { 12 | skin = defaultSkin, 13 | ...rest 14 | } = props 15 | 16 | return ( 17 | 22 | ) 23 | } 24 | 25 | Autoform = forwardRef(Autoform) 26 | -------------------------------------------------------------------------------- /src/ui/AutoformBase.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useImperativeHandle, forwardRef } from 'react' 2 | import { 3 | objectTraverse, 4 | isObject, 5 | deepmerge, 6 | getSkinComponent 7 | } from '../utils' 8 | import { useForm } from 'react-hook-form' 9 | import { getComponents, renderInputs } from './componentRender' 10 | import { useAutoformState } from '../autoform_state' 11 | 12 | import baseSkin from './baseSkin' 13 | 14 | /** 15 | * Creates a form using the provided skin. The form 16 | * has all the needed fields, styles and validation 17 | * errors in order to work. 18 | */ 19 | export let AutoformBase = (props, ref) => { 20 | const { 21 | schema, 22 | elementProps, 23 | initialValues = {}, 24 | children, 25 | onSubmit, 26 | onErrors, 27 | styles, 28 | submitButton, 29 | submitButtonText, 30 | skin, 31 | skinOverride, 32 | skipManualReset, 33 | ...rest 34 | } = props 35 | 36 | if (!schema) { 37 | throw new Error(' was rendered without schema.') 38 | } 39 | 40 | const formHook = useForm({ 41 | mode: 'all', 42 | defaultValues: initialValues 43 | }) 44 | const { 45 | control, 46 | formState, 47 | register, 48 | unregister, 49 | handleSubmit, 50 | reset 51 | } = formHook 52 | 53 | const finalSkin = { ...baseSkin, ...skin, ...skinOverride } 54 | 55 | const { 56 | coercedSubmit, 57 | coercedChange, 58 | setValue, 59 | setVisible, 60 | setHelperText, 61 | resetState, 62 | stateControl, 63 | getValues 64 | } = useAutoformState({ 65 | initialValues, 66 | onSubmit, 67 | onChange: props.onChange, 68 | schema, 69 | skin: finalSkin, 70 | formHook, 71 | skipManualReset 72 | }) 73 | 74 | const submit = handleSubmit(coercedSubmit, onErrors) 75 | 76 | useImperativeHandle(ref, () => ({ 77 | submit, 78 | formHook: () => formHook, 79 | setValue, 80 | setVisible, 81 | getValues, 82 | reset: resetState 83 | })) 84 | 85 | const inputProps = { 86 | ...rest, 87 | ...elementProps, 88 | reset, 89 | children, 90 | initialValues, 91 | schema, 92 | register, 93 | unregister, 94 | styles, 95 | skin: finalSkin, 96 | formHook, 97 | autoformProps: props, 98 | stateControl, 99 | setValue, 100 | setVisible, 101 | setHelperText, 102 | onChange: coercedChange 103 | } 104 | 105 | const Button = getSkinComponent(finalSkin.button) 106 | const Form = getSkinComponent(finalSkin.form) 107 | 108 | return ( 109 |
110 | {renderInputs(inputProps)} 111 | { 112 | submitButton && 113 | 120 | } 121 | {children} 122 |
123 | ) 124 | } 125 | 126 | AutoformBase = forwardRef(AutoformBase) 127 | -------------------------------------------------------------------------------- /src/ui/baseSkin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { InputArrayWrap } from './components/InputArrayWrap' 3 | import { Submodel } from './components/Submodel' 4 | import { getSkinComponent } from '../utils' 5 | 6 | function getOtherSchema(schemaDef, fieldName, { isArray }) { 7 | const field = schemaDef[fieldName] 8 | const { type } = field 9 | const other = isArray ? type[0] : type 10 | return other.getSchema() 11 | } 12 | 13 | import { deletedMark } from './deletedMark' 14 | 15 | export default { 16 | array: { 17 | skipRegister: true, 18 | nameForErrors: name => `${name}__count`, 19 | coerce: (arr = [], { coerceObject, schemaDef, fieldName }) => { 20 | const otherSchema = getOtherSchema(schemaDef, fieldName, { 21 | isArray: true 22 | }) 23 | 24 | if (Array.isArray(arr)) { 25 | return arr.map(entry => { 26 | if (entry[deletedMark]) 27 | return null 28 | else 29 | return coerceObject({ object: entry, schemaDef: otherSchema }) 30 | }).filter(entry => entry !== null) 31 | } else { 32 | return [] 33 | } 34 | }, 35 | props: props => { 36 | const { 37 | config = {}, 38 | fieldSchema, 39 | skin, 40 | ...rest 41 | } = props 42 | 43 | const { arrayMode } = config 44 | const finalArrayMode = fieldSchema.arrayMode || arrayMode 45 | const isTable = finalArrayMode == 'table' 46 | const ArrayTable = getSkinComponent(skin.arrayTable) 47 | const ArrayPanel = getSkinComponent(skin.arrayPanel) 48 | const arrayHandler = isTable ? ArrayTable : ArrayPanel 49 | 50 | return { 51 | ...rest, 52 | config, 53 | component: InputArrayWrap, 54 | initiallyEmpty: fieldSchema.initiallyEmpty, 55 | fieldSchema, 56 | arrayHandler, 57 | inline: true, 58 | noRef: true, 59 | isTable, 60 | skin 61 | } 62 | }, 63 | }, 64 | schema: { 65 | skipRegister: true, 66 | coerce: (obj = {}, { coerceObject, schemaDef, fieldName }) => { 67 | const otherSchema = getOtherSchema(schemaDef, fieldName, { isArray: false }) 68 | 69 | return coerceObject({ object: obj, schemaDef: otherSchema }) 70 | }, 71 | component: Submodel 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/componentRender.jsx: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react' 2 | 3 | import { 4 | schemaTypeEx, 5 | inputName 6 | } from '../utils' 7 | import { tr } from '../translate' 8 | import { trError } from '../translation_utils' 9 | import { FieldPropsOverride } from './components/FieldPropsOverride' 10 | import { AutofieldContainer } from './AutofieldContainer' 11 | 12 | const validations = { 13 | required: ({ value, message }) => message, 14 | maxLength: 'maxLength', 15 | minLength: 'minLength', 16 | max: 'max', 17 | min: 'min', 18 | pattern: 'pattern', 19 | validate: 'validate' 20 | } 21 | 22 | /** 23 | * Creates validation rules after schema 24 | * 25 | * @param {object} fieldSchema 26 | */ 27 | export function validationRules(fieldSchema) { 28 | const validationKeys = Object.keys(validations) 29 | return validationKeys.reduce((result, key) => { 30 | if (key in fieldSchema) { 31 | const validation = fieldSchema[key] 32 | let data 33 | if (typeof validation == 'object') { 34 | if (validation.message && typeof validation.message == 'function') 35 | validation.message = validation.message(fieldSchema) 36 | data = validation 37 | } else if (key == 'validate') { 38 | data = value => { 39 | const erroring = validation(value) 40 | return erroring === false || erroring 41 | } 42 | } else { 43 | data = { 44 | value: fieldSchema[key], 45 | message: trError(key, fieldSchema) 46 | } 47 | } 48 | 49 | result[key] = typeof validations[key] == 'function' ? 50 | validations[key](data) : data 51 | } 52 | 53 | return result 54 | }, {}) 55 | } 56 | 57 | /** 58 | * Searches in children to find overrides. 59 | */ 60 | function searchForOverrides(parent, name, children = []) { 61 | const childrenArr = Children.map(children, child => child) 62 | 63 | return childrenArr.reduce((override, child) => { 64 | const childName = child.props.name 65 | const dottedChild = childName && childName.replace(/(\[|\]\.)/g, '.') 66 | const isOverride = child.type == FieldPropsOverride 67 | if (isOverride && dottedChild == name) { 68 | const cloned = Object.assign({}, child.props) 69 | delete cloned.name 70 | 71 | return cloned 72 | } else { 73 | return override 74 | } 75 | }, {}) 76 | } 77 | 78 | /** 79 | * Renders a single field. 80 | * 81 | * @param {object} params 82 | * @param {string} params.field Name of the field 83 | * @param {object} params.fieldSchema Schema specification 84 | * for the field 85 | * @param {string} params.parent Prefix of the field name 86 | * @param {string} params.schemaTypeName Name of the schema 87 | * (first argument while instantiating a schema) 88 | * @param {object} params.config Form configuration 89 | * @param {...object} params.rest props passed to the component 90 | */ 91 | export function renderInput({ 92 | field, 93 | fieldSchema, 94 | fieldSchema: { 95 | type, 96 | required, 97 | defaultValue 98 | }, 99 | initialValue, 100 | parent, 101 | children, 102 | propOverrides, 103 | schemaTypeName, 104 | config = {}, 105 | index, 106 | skin, 107 | styles, 108 | ...rest 109 | }) { 110 | const strType = schemaTypeEx(type) 111 | 112 | function describePlace() { 113 | return `Schema "${schemaTypeName}" has field "${field}"` 114 | } 115 | 116 | if (!strType) { 117 | throw `${describePlace()} that lacks type description.` 118 | } 119 | 120 | const skinElement = skin[strType] 121 | 122 | if (!skinElement) { 123 | throw `${describePlace()} with type "${strType}" ` 124 | + 'that doesn\'t exist in skin.' 125 | } 126 | 127 | const rules = validationRules(fieldSchema) 128 | const fullField = inputName({ parent, index, field }) 129 | const id = `${schemaTypeName}-${fullField}` 130 | 131 | const overrides = searchForOverrides(parent, fullField, propOverrides) 132 | 133 | defaultValue = typeof initialValue == 'undefined' ? 134 | defaultValue : initialValue 135 | 136 | return ( 137 | 155 | ) 156 | } 157 | 158 | /** 159 | * Renders the inputs to make the schema work. 160 | * 161 | * @param {object} params 162 | * @param {Schema} params.schema Schema instance 163 | * @param {object} params.config Rendering configuration 164 | * @param {string} params.config.arrayMode 'panels' or 'table' 165 | * @param {...object} params.rest Props passed to every input 166 | * 167 | * @returns {array} React elements with the form and inputs. 168 | */ 169 | export function renderInputs({ 170 | schema, 171 | config = {}, 172 | children, 173 | propOverrides, 174 | initialValues = {}, 175 | styles = {}, 176 | ...rest 177 | }) { 178 | const schemaDef = schema.getSchema() 179 | const schemaKeys = Object.keys(schemaDef) 180 | 181 | return schemaKeys.map(field => 182 | renderInput({ 183 | ...rest, 184 | field, 185 | config, 186 | propOverrides: propOverrides || children, 187 | fieldSchema: schemaDef[field], 188 | schemaTypeName: schema.getType(), 189 | initialValue: initialValues[field], 190 | styles 191 | }) 192 | ) 193 | } 194 | -------------------------------------------------------------------------------- /src/ui/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Button = ({ styles, text, children, ...rest }) => 4 | 13 | -------------------------------------------------------------------------------- /src/ui/components/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import { trModel } from '../../translation_utils' 2 | 3 | export let Checkbox = ({ 4 | id, 5 | schemaTypeName, 6 | name, 7 | onChange, 8 | onBlur, 9 | defaultValue, 10 | inputRef, 11 | styles = {}, 12 | field 13 | }) => { 14 | const defaultChecked = defaultValue !== 'false' && defaultValue 15 | 16 | return ( 17 |
18 | 29 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/components/FieldPropsOverride.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Allows to specify extra props for a field in runtime. 5 | */ 6 | export const FieldPropsOverride = () => null 7 | -------------------------------------------------------------------------------- /src/ui/components/InputArrayPanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getSkinComponent } from '../../utils' 3 | 4 | const renderItemHeader = ({ styles, closeButton }) => { 5 | return ( 6 |
7 | {closeButton} 8 |
9 | ) 10 | } 11 | 12 | const renderItems = ({ styles, items, Panel }) => 13 | items.map(({ idx, closeButton, inputs }) => { 14 | const itemHeader = renderItemHeader({ styles, closeButton }) 15 | 16 | return ( 17 |
18 | 19 | {inputs} 20 | 21 |
22 | ) 23 | }) 24 | 25 | export const InputArrayPanel = (props) => { 26 | const { styles, skin } = props 27 | const Panel = getSkinComponent(skin.panel) 28 | 29 | return ( 30 | <> 31 |
32 | {renderItems({ ...props, Panel })} 33 |
34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/components/InputArrayTable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | // import { renderLectures } from './renderLectures' 3 | 4 | import { trField, trPath } from '../../translation_utils' 5 | 6 | const renderRemove = ({ idx, closeButton }) => 7 | 8 | {closeButton} 9 | 10 | 11 | const renderTableHeader = ({ schema }) => { 12 | const subType = schema.getType() 13 | const schemaDef = schema.getSchema() 14 | const fields = Object.keys(schemaDef) 15 | 16 | return ( 17 | 18 | 19 | { 20 | fields.map(sub => 21 | 22 | {trField(subType, sub)} 23 | 24 | ) 25 | } 26 | 27 | ) 28 | } 29 | 30 | const renderItems = ({ items }) => 31 | items.map(({ idx, closeButton, inputs }) => { 32 | const tdedInputs = inputs && inputs.map(input => { 33 | return ( 34 | 35 | {input} 36 | 37 | ) 38 | }) 39 | 40 | return ( 41 | 42 | {renderRemove({ idx, closeButton })} 43 | {tdedInputs} 44 | 45 | ) 46 | }) 47 | 48 | const renderTable = props => 49 | 50 | 51 | {renderTableHeader(props)} 52 | 53 | 54 | {renderItems(props)} 55 | 56 |
57 | 58 | export const InputArrayTable = (props) => 59 | <> 60 | {renderTable(props)} 61 | {/* {renderLectures({})} */} 62 | 63 | -------------------------------------------------------------------------------- /src/ui/components/InputArrayWrap.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useReducer, 4 | useRef, 5 | useEffect 6 | } from 'react' 7 | import classnames from 'classnames' 8 | 9 | import { renderInputs } from '../componentRender' 10 | import { inputArray } from '../ducks' 11 | import { tr, trModel } from '../../translation_utils' 12 | import { deletedMark } from '../deletedMark' 13 | import { inputName, getSkinComponent } from '../../utils' 14 | 15 | const renderAddButton = ({ onAdd, styles, Button, AddGlyph }) => { 16 | const boundAdd = e => { 17 | e.preventDefault() 18 | onAdd() 19 | } 20 | 21 | return ( 22 | 29 | ) 30 | } 31 | 32 | const renderCloseButton = ({ 33 | onRemove, 34 | idx, 35 | styles, 36 | Button, 37 | RemoveGlyph 38 | }) => { 39 | const boundRemove = e => { 40 | e.preventDefault() 41 | onRemove(idx) 42 | } 43 | 44 | return ( 45 | 52 | ) 53 | } 54 | 55 | const renderPanelHeader = ({ 56 | onAdd, 57 | schemaTypeName, 58 | aliveItems, 59 | name, 60 | styles, 61 | Button, 62 | AddGlyph, 63 | Div, 64 | Text 65 | }) => { 66 | const addButton = renderAddButton({ 67 | onAdd, 68 | styles, 69 | Button, 70 | AddGlyph, 71 | }) 72 | 73 | return ( 74 |
75 | 76 | {trModel(schemaTypeName, name, '_field') + ' '} 77 | 78 | {addButton} 79 |
80 | ) 81 | } 82 | 83 | /** 84 | * Used for the arrays in models, for 85 | * example clients: [Clients] 86 | * 87 | */ 88 | export let InputArrayWrap = ({ 89 | name, 90 | newObject, 91 | arrayHandler, 92 | register, 93 | unregister, 94 | errorText = '', 95 | fieldSchema, 96 | fieldSchema: { type }, 97 | schemaTypeName, 98 | formHook, 99 | defaultValue, 100 | initiallyEmpty, 101 | onRemove, 102 | config, 103 | styles, 104 | isTable, 105 | setValue, 106 | skin, 107 | skinElement, 108 | ...rest 109 | }) => { 110 | const [ items, dispatch ] = useReducer( 111 | inputArray.reducer, 112 | inputArray.initialFromDefault(defaultValue, initiallyEmpty) 113 | ) 114 | 115 | const schema = type[0] 116 | const $arrayHandler = arrayHandler 117 | 118 | const Button = getSkinComponent(skin.arrayButton) 119 | const AddGlyph = getSkinComponent(skin.addGlyph) 120 | const RemoveGlyph = getSkinComponent(skin.removeGlyph) 121 | const Panel = getSkinComponent(skin.panel) 122 | const Div = getSkinComponent(skin.div) 123 | const Text = getSkinComponent(skin.text) 124 | 125 | const aliveItems = items.keys.filter(idx => idx !== null) 126 | const counterField = skinElement.nameForErrors(name) 127 | 128 | const getErrorMessage = (num) => { 129 | if ('minChildren' in fieldSchema) { 130 | const { minChildren } = fieldSchema 131 | 132 | if (num < minChildren) 133 | return tr('error.minChildren', { minChildren }) 134 | } 135 | 136 | if ('maxChildren' in fieldSchema) { 137 | const { maxChildren } = fieldSchema 138 | if (num > maxChildren) 139 | return tr('error.maxChildren', { maxChildren }) 140 | } 141 | } 142 | 143 | const checkSetErrorMessage = (num) => { 144 | const message = getErrorMessage(num) 145 | if (message) { 146 | formHook.setError(counterField, { 147 | type: 'manual', 148 | message 149 | }) 150 | } else { 151 | formHook.clearErrors(counterField) 152 | } 153 | } 154 | 155 | const handleAdd = () => { 156 | dispatch(inputArray.add()) 157 | checkSetErrorMessage(items.num + 1) 158 | } 159 | 160 | const itemsInputs = aliveItems.map(idx => { 161 | const handleRemove = (removeIdx) => { 162 | dispatch(inputArray.remove(removeIdx)) 163 | checkSetErrorMessage(items.num - 1) 164 | 165 | const taint = `${name}.${removeIdx}.${deletedMark}` 166 | setValue(taint, true) 167 | 168 | const fieldNames = schema.getFieldNames() 169 | fieldNames.forEach(fieldName => { 170 | const toUnregister = inputName({ 171 | parent: name, 172 | index: removeIdx, 173 | field: fieldName 174 | }) 175 | unregister(toUnregister) 176 | }) 177 | } 178 | 179 | const closeButton = renderCloseButton({ 180 | onRemove: handleRemove, 181 | idx, 182 | styles, 183 | Button, 184 | RemoveGlyph 185 | }) 186 | 187 | let itemDefault 188 | if (defaultValue && Array.isArray(defaultValue)) 189 | itemDefault = defaultValue[idx] 190 | else 191 | itemDefault = defaultValue 192 | 193 | return { 194 | idx, 195 | closeButton, 196 | inputs: renderInputs({ 197 | ...rest, 198 | inline: isTable, 199 | schema, 200 | schemaTypeName, 201 | setValue, 202 | parent: name, 203 | index: idx, 204 | initialValues: itemDefault, 205 | formHook, 206 | styles, 207 | register, 208 | unregister, 209 | arrayIdx: idx, 210 | arrayInitialValues: itemDefault, 211 | skin, 212 | arrayControl: { 213 | items, 214 | index: idx, 215 | remove: handleRemove, 216 | add: handleAdd 217 | } 218 | }) 219 | } 220 | }) 221 | 222 | const panelProps = { 223 | onAdd: handleAdd, 224 | schemaTypeName, 225 | dispatch, 226 | name, 227 | styles, 228 | Button, 229 | AddGlyph, 230 | Div, 231 | Text 232 | } 233 | 234 | const panelClasses = classnames({ 235 | [styles.errored]: errorText 236 | }) 237 | 238 | return ( 239 | 244 | <$arrayHandler 245 | schema={schema} 246 | config={config} 247 | name={name} 248 | errorText={errorText} 249 | component={arrayHandler} 250 | onAdd={handleAdd} 251 | newObject={newObject} 252 | items={itemsInputs} 253 | defaultValue={defaultValue} 254 | schemaTypeName={schemaTypeName} 255 | styles={styles} 256 | skin={skin} 257 | {...rest} 258 | /> 259 | 260 | ) 261 | } 262 | -------------------------------------------------------------------------------- /src/ui/components/InputWrap.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | import { trField } from '../../translation_utils' 3 | 4 | const Wrap = ({ 5 | id, 6 | name, 7 | children, 8 | inline, 9 | styles, 10 | label, 11 | ...rest 12 | }) => { 13 | if (inline) { 14 | return ( 15 |
16 | {children} 17 |
18 | ) 19 | } else { 20 | return ( 21 |
26 | 33 | {children} 34 |
35 | ) 36 | } 37 | } 38 | 39 | export let InputWrap = (props, ref) => { 40 | const { 41 | id, 42 | name, 43 | formHook, 44 | children, 45 | schemaTypeName, 46 | styles, 47 | labelOverride, 48 | inline, 49 | addWrapperProps, 50 | helperText = '', 51 | errorText = '' 52 | } = props 53 | 54 | const label = typeof labelOverride != 'undefined' ? 55 | labelOverride : trField(props) 56 | 57 | return ( 58 | 66 | {children} 67 | { helperText && 68 |
69 | {helperText} 70 |
71 | } 72 | { errorText && 73 |
74 |
75 | {errorText} 76 |
77 |
78 | } 79 |
80 | ) 81 | } 82 | 83 | InputWrap = forwardRef(InputWrap) 84 | -------------------------------------------------------------------------------- /src/ui/components/Panel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | export const Panel = ({ 5 | className, 6 | header, 7 | children, 8 | noMargin, 9 | styles = {} 10 | }) => { 11 | const panelClasses = classnames(styles.panel, className) 12 | const contentClasses = classnames(className, styles.content, { 13 | [styles.contentMargin]: !noMargin 14 | }) 15 | 16 | return ( 17 |
18 |
19 | {header} 20 |
21 |
22 | {children} 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/components/Radio.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | 3 | export let Radio = ({ 4 | id, 5 | schemaTypeName, 6 | name, 7 | option, 8 | onChange, 9 | onBlur, 10 | inputRef, 11 | label, 12 | styles, 13 | field, 14 | defaultValue 15 | }, ref) => { 16 | const fullId = `${id}-${option}` 17 | const checked = defaultValue == option 18 | 19 | return ( 20 |
21 | 33 | 40 |
41 | ) 42 | } 43 | 44 | Radio = forwardRef(Radio) 45 | -------------------------------------------------------------------------------- /src/ui/components/RadiosWrap.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const RadiosWrap = ({ 4 | children 5 | }) => 6 | <> 7 | {children} 8 | 9 | -------------------------------------------------------------------------------- /src/ui/components/Select.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | import { processOptions } from '../../utils' 3 | 4 | export let Select = (props) => { 5 | const { 6 | name, 7 | styles, 8 | onChange, 9 | onBlur, 10 | inputRef, 11 | defaultValue 12 | } = props 13 | 14 | const optionsProcessed = processOptions({ 15 | ...props, 16 | addDefault: true, 17 | }) 18 | 19 | return ( 20 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/components/Submodel.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | 3 | import { renderInputs } from '../componentRender' 4 | import { trModel } from '../../translation_utils' 5 | import { getSkinComponent } from '../../utils' 6 | 7 | export let Submodel = ({ 8 | config = {}, 9 | name, 10 | field, 11 | fieldSchema: { type }, 12 | defaultValue, 13 | styles, 14 | skin, 15 | ...rest 16 | }, ref) => { 17 | const inputsConf = { 18 | ...rest, 19 | schema: type, 20 | config, 21 | parent: name, 22 | initialValues: defaultValue, 23 | styles, 24 | skin 25 | } 26 | const schemaTypeName = type.getType() 27 | const Panel = getSkinComponent(skin.panel) 28 | 29 | return ( 30 | 34 | {renderInputs(inputsConf)} 35 | 36 | ) 37 | } 38 | 39 | Submodel = forwardRef(Submodel) 40 | -------------------------------------------------------------------------------- /src/ui/components/index.js: -------------------------------------------------------------------------------- 1 | export { InputWrap } from './InputWrap' 2 | export { Panel } from './Panel' 3 | export { Button } from './Button' 4 | -------------------------------------------------------------------------------- /src/ui/defaultSkin.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | import { InputWrap } from './components/InputWrap' 5 | import { RadiosWrap } from './components/RadiosWrap' 6 | import { Radio } from './components/Radio' 7 | import { InputArrayTable } from './components/InputArrayTable' 8 | import { InputArrayPanel } from './components/InputArrayPanel' 9 | import { Select } from './components/Select' 10 | import { Checkbox } from './components/Checkbox' 11 | import { Button } from './components/Button' 12 | import { Panel } from './components/Panel' 13 | import { RemoveGlyph } from './svgs/RemoveGlyph' 14 | import { AddGlyph } from './svgs/AddGlyph' 15 | 16 | import { processOptions } from '../utils' 17 | 18 | function standardClasses(props) { 19 | return classnames( 20 | props.styles.input, 21 | props.styles.standard 22 | ) 23 | } 24 | 25 | export default { 26 | defaultWrap: InputWrap, 27 | string: { 28 | props: props => ({ 29 | ...props, 30 | component: props.fieldSchema.textarea ? 'textarea' : 'input', 31 | type: 'text' 32 | }), 33 | }, 34 | password: { 35 | props: { 36 | component: 'input', 37 | type: 'password' 38 | } 39 | }, 40 | number: { 41 | coerce: value => parseFloat(value), 42 | props: { 43 | component: 'input', 44 | type: 'number' 45 | } 46 | }, 47 | range: { 48 | coerce: value => parseFloat(value), 49 | props: { 50 | component: 'input', 51 | type: 'range' 52 | } 53 | }, 54 | radios: { 55 | props: (props) => { 56 | const { 57 | schemaTypeName, 58 | field, 59 | fieldSchema, 60 | ref, 61 | ...rest 62 | } = props 63 | const { options } = fieldSchema 64 | const optionsProcessed = processOptions({ 65 | schemaTypeName, 66 | field, 67 | options, 68 | ...rest 69 | }) 70 | 71 | return { 72 | ...rest, 73 | schemaTypeName, 74 | field, 75 | fieldSchema, 76 | component: RadiosWrap, 77 | noRef: true, 78 | children: optionsProcessed.map(op => { 79 | return ( 80 | 88 | ) 89 | }) 90 | } 91 | } 92 | }, 93 | select: { 94 | component: Select 95 | }, 96 | boolean: { 97 | coerce: value => Boolean(value), 98 | props: (props) => { 99 | return { 100 | ...props, 101 | component: Checkbox, 102 | inline: true 103 | } 104 | } 105 | }, 106 | button: { 107 | component: Button 108 | }, 109 | arrayButton: { 110 | component: Button 111 | }, 112 | form: { 113 | component: ({ children, ...rest }) => 114 |
115 | {children} 116 |
117 | }, 118 | panel: { 119 | component: Panel 120 | }, 121 | addGlyph: { 122 | component: AddGlyph 123 | }, 124 | removeGlyph: { 125 | component: RemoveGlyph 126 | }, 127 | arrayTable: { 128 | component: InputArrayTable 129 | }, 130 | arrayPanel: { 131 | component: InputArrayPanel 132 | }, 133 | div: { 134 | component: props =>
135 | }, 136 | text: { 137 | component: ({ children }) => children 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ui/deletedMark.js: -------------------------------------------------------------------------------- 1 | // We will put this thing in a field in 2 | // order to consider it deleted 3 | 4 | export const deletedMark = '__rhfa_deleted' 5 | -------------------------------------------------------------------------------- /src/ui/ducks/index.js: -------------------------------------------------------------------------------- 1 | import * as inputArray from './inputArray' 2 | export { inputArray } 3 | 4 | -------------------------------------------------------------------------------- /src/ui/ducks/inputArray.js: -------------------------------------------------------------------------------- 1 | export const REMOVE = 'REMOVE' 2 | export const ADD = 'ADD' 3 | 4 | export const remove = (idx) => ({ 5 | type: REMOVE, 6 | idx 7 | }) 8 | 9 | export const add = () => ({ 10 | type: ADD 11 | }) 12 | 13 | export const initialEmpty = { last: 0, num: 0, keys: [] } 14 | export const initial = { last: 1, num: 1, keys: [0] } 15 | 16 | export const initialFromDefault = (defaultValue, initiallyEmpty) => { 17 | if (defaultValue && Array.isArray(defaultValue)) { 18 | return { 19 | last: defaultValue.length, 20 | num: defaultValue.length, 21 | keys: defaultValue.map((_, idx) => idx) 22 | } 23 | } else { 24 | return initiallyEmpty ? initialEmpty : initial 25 | } 26 | } 27 | 28 | export const reducer = (state = initial, action) => { 29 | switch (action.type) { 30 | case REMOVE: 31 | const { keys } = state 32 | 33 | return { 34 | last: state.last, 35 | num: state.num - 1, 36 | keys: [ 37 | ...keys.slice(0, action.idx), 38 | null, 39 | ...keys.slice(action.idx + 1) 40 | ] 41 | } 42 | case ADD: 43 | return { 44 | last: state.last + 1, 45 | num: state.num + 1, 46 | keys: [ ...state.keys, state.last ] 47 | } 48 | default: 49 | return state 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/svgs/AddGlyph.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { textSvgStyle } from './svgUtils' 3 | 4 | export const AddGlyph = () => 5 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /src/ui/svgs/RemoveGlyph.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { textSvgStyle } from './svgUtils' 3 | 4 | export const RemoveGlyph = () => 5 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /src/ui/svgs/svgUtils.js: -------------------------------------------------------------------------------- 1 | export const textSvgStyle = { 2 | width: '1em', 3 | height: '1em' 4 | } 5 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { trModel } from './translation_utils' 2 | 3 | /** 4 | * Translates schema specification type. Types can 5 | * be specified with a string or a constructor like 6 | * String. 7 | * 8 | * @param {string|function} type Type specification. 9 | * 10 | * @returns {string} Type as string. 11 | */ 12 | export const schemaType = type => { 13 | if (typeof type == 'function') 14 | return typeof type() 15 | else 16 | return type 17 | } 18 | 19 | /** 20 | * Translates the schema's type specification. Type 21 | * can be specified as with schemaType and also can 22 | * be a subschema or an array of other schema. 23 | * 24 | * @param {any} type Can be: 25 | * - String like 'number' 26 | * - Constructor like Number 27 | * - Schema instance 28 | * - Array with schema instance in the first element. 29 | * Example: [client] 30 | */ 31 | export function schemaTypeEx(type) { 32 | if (typeof type == 'object' && type._type == 'schema') 33 | return 'schema' 34 | else { 35 | const isArray = Array.isArray(type) 36 | const first = type && type[0] 37 | const isSchema = isArray 38 | && type.length > 0 39 | && first._type 40 | && first._type == 'schema' 41 | 42 | if (isSchema) 43 | return 'array' 44 | else 45 | return schemaType(type) 46 | } 47 | } 48 | 49 | // Thanks Mariuzzo 50 | // https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge 51 | 52 | /** 53 | * Simple object check. 54 | * @param item 55 | * @returns {boolean} 56 | */ 57 | export function isObject(item) { 58 | return typeof item == 'object' && !(item instanceof Date) 59 | } 60 | 61 | /** 62 | * Deep merge two objects. 63 | * @param target 64 | * @param ...sources 65 | */ 66 | export function deepmerge(target, ...sources) { 67 | if (!sources.length) 68 | return target 69 | const source = sources.shift() 70 | 71 | if (isObject(target) && isObject(source)) { 72 | for (const key in source) { 73 | if (isObject(source[key])) { 74 | const sourceIsArray = Array.isArray(source[key]) 75 | if (!target[key]) { 76 | if (sourceIsArray) 77 | Object.assign(target, { [key]: [] }) 78 | else 79 | Object.assign(target, { [key]: {} }) 80 | } 81 | // Arrays are overwritten 82 | if (sourceIsArray) 83 | target[key] = [ ...source[key] ] 84 | else 85 | deepmerge(target[key], source[key]) 86 | } else { 87 | Object.assign(target, { [key]: source[key] }) 88 | } 89 | } 90 | } 91 | 92 | return deepmerge(target, ...sources) 93 | } 94 | 95 | export function createNumberedArray(length) { 96 | return Array.from({ length }, (_, k) => k) 97 | } 98 | 99 | /** 100 | * Converts options from different formats to 101 | * [ { label, value } ] 102 | * 103 | * You can usually pass control props here. Options will 104 | * be acquired from fieldSchema. 105 | * 106 | * @param {string} schemaTypeName Model name 107 | * @param {string} field Field name 108 | * @param {function|array} options Array with options. If 109 | * it's a function it will be called with props. 110 | * Array (or resulting one after calling) will be processed 111 | * to populate label and value. 112 | */ 113 | export function processOptions({ 114 | fieldSchema, 115 | schemaTypeName, 116 | field, 117 | options, 118 | addDefault, 119 | ...rest 120 | }) { 121 | if (fieldSchema && !options) 122 | options = fieldSchema.options 123 | 124 | const extracted = typeof options == 'function' ? 125 | options({ name, field, schemaTypeName, ...rest }) : options 126 | 127 | const getLabel = option => trModel(schemaTypeName, field, option) 128 | 129 | const processed = extracted.map(option => { 130 | if (typeof option == 'string') { 131 | return { 132 | value: option, 133 | label: getLabel(option) 134 | } 135 | } else { 136 | if ('key' in option) { 137 | return { 138 | ...option, 139 | label: option.label || getLabel(option.key) 140 | } 141 | } else 142 | return option 143 | } 144 | }) 145 | 146 | if (addDefault) { 147 | return [{ 148 | label: trModel(schemaTypeName, field, '_default'), 149 | value: '' 150 | }, ...processed] 151 | } else { 152 | return processed 153 | } 154 | } 155 | 156 | /** 157 | * Transforms typical form path to array. Example: 158 | * 159 | * `pathToArray("pets[4].name") --> ['pets', '4', 'name']` 160 | * `pathToArray("pets.4.name") --> ['pets', '4', 'name']` 161 | */ 162 | export function pathToArray(path) { 163 | const unsquared = path.replace(/[[.](.*?)[\].]/g, '.$1.') 164 | return unsquared.split('.') 165 | } 166 | 167 | /** 168 | * Traverses an object using an array of keys. 169 | * 170 | * @param {object} object Object to traverse 171 | * @param {string|array} path Path in the form `"pets.4.name"`, 172 | * `"pets[4].name"` or `['pets', '4', 'name']` 173 | * @param {object} options Optional options: 174 | * { 175 | * createIfMissing: false, // Creates missing entities with objects, 176 | * returnValue: false, // Ultimate value if you are not interested 177 | * // in context 178 | * } 179 | * 180 | * @returns {array} Array in the form `[ object, attribute ]` 181 | * (or empty if subobject is not found). 182 | * 183 | * This allows you to mutate original object like this: 184 | * 185 | * const [ container, attribute ] = objectTraverse(obj, path) 186 | * container[attribute] = newValue 187 | * 188 | * TODO When createIfMissing, use path brackets as a 189 | * hint to when to create arrays or objects 190 | */ 191 | export function objectTraverse(object, pathOrArray, options = {}) { 192 | const { 193 | createIfMissing, 194 | returnValue 195 | } = options 196 | 197 | const arrayed = Array.isArray(pathOrArray) ? 198 | pathOrArray : pathToArray(pathOrArray) 199 | const [ next, ...rest ] = arrayed 200 | 201 | if (next in object) { 202 | if (rest.length == 0) { 203 | if (returnValue) 204 | return object[next] 205 | else 206 | return [ object, next ] 207 | } else { 208 | if (createIfMissing && typeof object[next] == 'undefined') 209 | object[next] = {} 210 | 211 | return objectTraverse(object[next], rest, options) 212 | } 213 | } else { 214 | if (createIfMissing) { 215 | object[next] = {} 216 | 217 | // Repeat 218 | return objectTraverse(object, arrayed, options) 219 | } else { 220 | if (returnValue) 221 | return null 222 | else 223 | return [] 224 | } 225 | } 226 | } 227 | 228 | /** 229 | * Returns input name in the form 'parent.index.field' 230 | * 231 | * @param {string} parent Optional parent 232 | * @param {number|string} index Optional index 233 | * @param {string} field Field 234 | * 235 | * @returns {string} Depends: 236 | * - If you passed index, then '..' 237 | * - Else if you passed parent, then '.' 238 | * - Else field 239 | */ 240 | export function inputName({ parent, index, field }) { 241 | if (typeof index == 'undefined') 242 | return parent ? `${parent}.${field}` : field 243 | else 244 | return `${parent || ''}.${index}.${field}` 245 | } 246 | 247 | /** 248 | * If attr is not found in object, we create it in the form 249 | * object[attr] = defaultObject 250 | * 251 | * @param {object} object Object 252 | * @param {string} attr Key 253 | * @param {function} create Function that returns a brand new 254 | * object to assign if it didn't exist. Important: It must be 255 | * a new object. 256 | * 257 | * @returns New or existing object[attr] 258 | * 259 | * @example 260 | * const obj = { existing: { count: 42 } } 261 | * 262 | * valueOrCreate(obj, 'existing', () => ({ count: 0 })) 263 | * // -> { count: 42 } 264 | * valueOrCreate(obj, 'invented', () => ({ count: 0 })) 265 | * // -> { count: 0 } 266 | */ 267 | export function valueOrCreate(object, attr, create) { 268 | if (!(attr in object)) 269 | object[attr] = create() 270 | 271 | return object[attr] 272 | } 273 | 274 | /** 275 | * @param {any} thing If thing is an event, value 276 | * will be extracted. I consider event anything 277 | * that has target with type 278 | * @returns {any} value 279 | */ 280 | export function valueFromEvent(thing) { 281 | if ('target' in thing) { 282 | const { 283 | target, 284 | target: { 285 | type, 286 | value 287 | } 288 | } = thing 289 | 290 | switch (type) { 291 | case 'checkbox': 292 | return target.checked 293 | default: 294 | return value 295 | } 296 | } else { 297 | return thing 298 | } 299 | } 300 | 301 | /** 302 | * Gets props transform from skin element (formelly `render`) 303 | */ 304 | export function getPropsTransform(skinElement) { 305 | return skinElement.props || skinElement.render 306 | } 307 | 308 | /** 309 | * Gets component from skin element 310 | */ 311 | export function getSkinComponent(skinElement) { 312 | return skinElement.component || skinElement.render 313 | } 314 | -------------------------------------------------------------------------------- /test/addProps.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { createSchema } from '../src/index' 7 | 8 | import { Autoform } from './utils/buttonHack' 9 | import { InputWrap } from '../src/ui/components/InputWrap' 10 | 11 | const pass = createSchema('passStuff', { 12 | addProps: { 13 | type: 'string', 14 | addWrapperProps: { thing: 'wrap' }, 15 | addInputProps: { thing: 'input' } 16 | } 17 | }) 18 | 19 | test('Be able to pass props to wrapper and input', () => { 20 | const app = mount( 21 | 22 | ) 23 | 24 | // console.log(app.debug()) 25 | 26 | const wrap = app.find('div[thing="wrap"]') 27 | expect(wrap.prop('thing')).toBe('wrap') 28 | 29 | const input = app.find('input[name="addProps"]') 30 | expect(input.prop('thing')).toBe('input') 31 | }) 32 | -------------------------------------------------------------------------------- /test/coerce.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import './utils/enzymeConfig' 7 | import { changeInput } from './utils/changeField' 8 | import { 9 | createSchema, 10 | Autoform, 11 | } from '../src/index' 12 | 13 | const sub = createSchema('sub', { 14 | amount: { 15 | type: 'number' 16 | } 17 | }) 18 | 19 | const custom = createSchema('complex', { 20 | name: { 21 | type: 'string', 22 | }, 23 | subs: { 24 | type: [sub] 25 | }, 26 | sub: { 27 | type: sub 28 | } 29 | }) 30 | 31 | test('Coerces', async () => { 32 | let doSubmit 33 | const wasSubmitted = new Promise((resolve, reject) => { 34 | doSubmit = resolve 35 | }) 36 | const mockSubmit = jest.fn(() => { 37 | doSubmit() 38 | }) 39 | 40 | const app = mount( 41 | 42 | ) 43 | 44 | const input = app.find('input[name="name"]') 45 | await changeInput(input, 'Hello') 46 | 47 | const amount0 = app.find('input[name="subs.0.amount"]') 48 | await changeInput(amount0, '42') 49 | 50 | const amount = app.find('input[name="sub.amount"]') 51 | await changeInput(amount, '666') 52 | 53 | const Form = app.find(Autoform) 54 | await act(async () => { 55 | await Form.simulate('submit') 56 | }) 57 | 58 | expect.assertions(2) 59 | const { calls } = mockSubmit.mock 60 | return wasSubmitted.then(() => { 61 | expect(calls.length).toBe(1) 62 | expect(calls[0][0]).toStrictEqual({ 63 | name: 'Hello', 64 | subs: [ 65 | { 66 | amount: 42 67 | } 68 | ], 69 | sub: { 70 | amount: 666 71 | } 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/controlled.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { createSchema } from '../src/index' 7 | 8 | import { Autoform } from './utils/buttonHack' 9 | import { createParenter } from './utils/createParenter' 10 | 11 | const parent = createParenter() 12 | 13 | const initial = { 14 | name: 'Father', 15 | childs: [ 16 | { 17 | name: 'One' 18 | }, 19 | { 20 | name: 'Two' 21 | } 22 | ], 23 | child: { 24 | name: 'Uniquer' 25 | }, 26 | boolean: true 27 | } 28 | 29 | const ControlledString = ({ 30 | name, 31 | onChange, 32 | onBlur, 33 | value 34 | }) => { 35 | return ( 36 | 42 | ) 43 | } 44 | 45 | const skin = { 46 | string: { 47 | controlled: true, 48 | component: ControlledString 49 | } 50 | } 51 | 52 | test('Sets initial values on controlled components', () => { 53 | const app = mount( 54 | 59 | ) 60 | 61 | const values = app.find(ControlledString) 62 | expect(app.find(ControlledString)).toHaveLength(4) 63 | 64 | const child1Name = app.find('input[name="childs.0.name"]') 65 | expect(child1Name.prop('value')).toBe('One') 66 | 67 | const child2Name = app.find('input[name="childs.1.name"]') 68 | expect(child2Name.prop('value')).toBe('Two') 69 | 70 | const aloneName = app.find('input[name="child.name"]') 71 | expect(aloneName.prop('value')).toBe('Uniquer') 72 | }) 73 | -------------------------------------------------------------------------------- /test/custom-element.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React, { forwardRef } from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { 7 | createSchema, 8 | Autoform, 9 | setLanguageByName 10 | } from '../src/index' 11 | 12 | let MyInput = (props, ref) => 13 |
14 | 15 | MyInput = forwardRef(MyInput) 16 | 17 | const skinOverride = { 18 | thingy: { 19 | render: { 20 | component: MyInput 21 | } 22 | } 23 | } 24 | 25 | const custom = createSchema('custom', { 26 | name: { 27 | type: 'thingy', 28 | } 29 | }) 30 | 31 | test('custom type with UI', () => { 32 | const app = mount( 33 | 34 | ) 35 | 36 | const form = app.find('form') 37 | const inputs = form.find('.fancy-class') 38 | 39 | expect(inputs).toHaveLength(1) 40 | }) 41 | -------------------------------------------------------------------------------- /test/default-values.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { createSchema } from '../src/index' 7 | 8 | import { Autoform } from './utils/buttonHack' 9 | 10 | const defaulter = createSchema('defaulter', { 11 | name: { 12 | type: 'string', 13 | defaultValue: 'tomato' 14 | } 15 | }) 16 | 17 | const uniparenter = createSchema('parenter', { 18 | defaulter: { 19 | type: defaulter 20 | } 21 | }) 22 | 23 | const multiparenter = createSchema('multiparenter', { 24 | defaulter: { 25 | type: [defaulter] 26 | } 27 | }) 28 | 29 | test('fields can have default values', () => { 30 | const app = mount( 31 | 32 | ) 33 | 34 | const form = app.find('form') 35 | const inputs = form.find('input') 36 | 37 | expect(inputs).toHaveLength(1) 38 | const input = inputs.first() 39 | expect(input.prop('defaultValue')).toBe('tomato') 40 | }) 41 | 42 | test('embedded object can have default values', () => { 43 | const app = mount( 44 | 45 | ) 46 | 47 | const form = app.find('form') 48 | const inputs = form.find('input') 49 | 50 | expect(inputs).toHaveLength(1) 51 | const input = inputs.first() 52 | expect(input.prop('defaultValue')).toBe('tomato') 53 | }) 54 | 55 | test('array objects can have default values', () => { 56 | const app = mount( 57 | , 58 | ) 59 | 60 | const form = app.find('form') 61 | const inputs = form.find('input') 62 | 63 | expect(inputs).toHaveLength(1) 64 | const input = inputs.first() 65 | expect(input.prop('defaultValue')).toBe('tomato') 66 | }) 67 | -------------------------------------------------------------------------------- /test/defaultSkin/array.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import '../utils/enzymeConfig' 6 | import { 7 | createSchema, 8 | Autoform, 9 | Button 10 | } from '../../src/index' 11 | 12 | const subpet = createSchema('subpet', { 13 | name: { type: 'string' } 14 | }) 15 | 16 | const pet = createSchema('pet', { 17 | name: { type: 'string' }, 18 | subpet: { type: subpet } 19 | }) 20 | 21 | const custom = createSchema('stacker', { 22 | array: { type: [pet] } 23 | }) 24 | 25 | test('Allows field arrays', () => { 26 | const app = mount( 27 | 28 | ) 29 | 30 | // console.log('-', app.html()) 31 | 32 | const buttons = app.find(Button) 33 | expect(buttons).toHaveLength(2) 34 | 35 | const submodel = app.find('input[name="array.0.subpet.name"]') 36 | expect(submodel).toHaveLength(1) 37 | 38 | const add = buttons.first() 39 | add.simulate('click') 40 | 41 | const someFields = app.find('input[name="array.1.name"]') 42 | expect(someFields).toHaveLength(1) 43 | 44 | // console.log('+', app.html()) 45 | 46 | const moreButtons = app.find(Button) 47 | expect(moreButtons).toHaveLength(3) 48 | }) 49 | 50 | -------------------------------------------------------------------------------- /test/defaultSkin/boolean.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('binarier', { 13 | bin: { 14 | type: 'boolean' 15 | } 16 | }) 17 | 18 | test('Allows checkboxes', () => { 19 | const app = mount( 20 | 21 | ) 22 | 23 | const input = app.find('input[name="bin"]') 24 | expect(input.prop('type')).toBe('checkbox') 25 | 26 | const label = app.find('label') 27 | expect(label).toHaveLength(1) 28 | expect(label.prop('htmlFor')).toBe('binarier-bin') 29 | expect(label.contains('models.binarier.bin')).toBe(true) 30 | }) 31 | 32 | -------------------------------------------------------------------------------- /test/defaultSkin/number.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('numberer', { 13 | num: { 14 | type: 'number' 15 | } 16 | }) 17 | 18 | test('Allows number fields', () => { 19 | const app = mount( 20 | 21 | ) 22 | 23 | const input = app.find('input[name="num"]') 24 | expect(input.prop('type')).toBe('number') 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /test/defaultSkin/password.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('passworder', { 13 | pass: { 14 | type: 'password' 15 | } 16 | }) 17 | 18 | test('Allows password fields', () => { 19 | const app = mount( 20 | 21 | ) 22 | 23 | const input = app.find('input[name="pass"]') 24 | expect(input.prop('type')).toBe('password') 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /test/defaultSkin/radios.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('radiator', { 13 | radios: { 14 | type: 'radios', 15 | options: ['op1', 'op2'] 16 | } 17 | }) 18 | 19 | test('Allows radios', () => { 20 | const app = mount( 21 | 22 | ) 23 | 24 | const labels = app.find('label') 25 | expect(labels).toHaveLength(3) 26 | const firstLabel = labels.first() 27 | expect(firstLabel.contains('models.radiator.radios._field')).toBe(true) 28 | 29 | const op1 = app.find('#radiator-radios-op1') 30 | expect(op1.prop('type')).toBe('radio') 31 | expect(op1.prop('value')).toBe('op1') 32 | 33 | const ops = app.find('input[type="radio"]') 34 | expect(ops).toHaveLength(2) 35 | }) 36 | 37 | -------------------------------------------------------------------------------- /test/defaultSkin/range.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('ranger', { 13 | range: { 14 | type: 'range' 15 | } 16 | }) 17 | 18 | test('Allows number fields', () => { 19 | const app = mount( 20 | 21 | ) 22 | 23 | const input = app.find('input[name="range"]') 24 | expect(input.prop('type')).toBe('range') 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /test/defaultSkin/select.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | } from '../../src/index' 11 | 12 | const custom = createSchema('selector', { 13 | select: { 14 | type: 'select', 15 | options: ['op1', 'op2'] 16 | } 17 | }) 18 | 19 | test('Allows select', () => { 20 | const app = mount( 21 | 22 | ) 23 | 24 | const label = app.find('label') 25 | expect(label).toHaveLength(1) 26 | expect(label.contains('models.selector.select._field')).toBe(true) 27 | 28 | const op1 = app.find('option[value="op1"]') 29 | expect(op1.contains('models.selector.select.op1')).toBe(true) 30 | 31 | const ops = app.find('option') 32 | expect(ops).toHaveLength(3) 33 | }) 34 | 35 | -------------------------------------------------------------------------------- /test/defaultSkin/subschema.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import '../utils/enzymeConfig' 7 | import { 8 | createSchema, 9 | Autoform, 10 | Button 11 | } from '../../src/index' 12 | 13 | const pet = createSchema('pet', { 14 | name: { type: 'string' } 15 | }) 16 | 17 | const custom = createSchema('owner', { 18 | fav: { type: pet } 19 | }) 20 | 21 | test('Allows submodels', () => { 22 | const app = mount( 23 | 24 | ) 25 | 26 | const input = app.find('input[name="fav.name"]') 27 | expect(input).toHaveLength(1) 28 | 29 | const label = app.find('label[htmlFor="pet-fav.name"]') 30 | expect(label.contains('models.pet.name._field')).toBe(true) 31 | }) 32 | 33 | -------------------------------------------------------------------------------- /test/field-override.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { 7 | createSchema, 8 | Autoform, 9 | FieldPropsOverride 10 | } from '../src/index' 11 | import { changeInput } from './utils/changeField' 12 | 13 | const custom = createSchema('custom', { 14 | name: { 15 | type: 'string', 16 | } 17 | }) 18 | 19 | test('Is able to override specific field props', () => { 20 | const app = mount( 21 | 22 | 26 | 27 | ) 28 | 29 | const form = app.find('form') 30 | const inputs = form.find('input') 31 | 32 | expect(inputs).toHaveLength(1) 33 | const input = inputs.first() 34 | expect(input.prop('type')).toBe('custom-type') 35 | }) 36 | 37 | test('Is able to override onChange while allowing changes', async () => { 38 | const handleChange = jest.fn() 39 | 40 | const app = mount( 41 | 42 | 46 | 47 | ) 48 | 49 | const name = app.find('input[name="name"]') 50 | 51 | expect(name).toHaveLength(1) 52 | 53 | await changeInput(name, 'Nerea') 54 | 55 | const { calls } = handleChange.mock 56 | const context = calls[0][1] 57 | 58 | expect(context.name).toBe('name') 59 | expect('setVisible' in context).toBe(true) 60 | expect('setHelperText' in context).toBe(true) 61 | expect('formHook' in context).toBe(true) 62 | expect('setValue' in context).toBe(true) 63 | expect(context.arrayControl).toBe(undefined) 64 | 65 | expect(name.instance().value).toBe('Nerea') 66 | }) 67 | 68 | const parent = createSchema('parent', { 69 | child: { 70 | type: [custom] 71 | } 72 | }) 73 | 74 | 75 | test('Is able to reference child elements', () => { 76 | const app = mount( 77 | 78 | 82 | 83 | ) 84 | 85 | const form = app.find('form') 86 | const inputs = form.find('input') 87 | 88 | expect(inputs).toHaveLength(1) 89 | const input = inputs.first() 90 | expect(input.prop('type')).toBe('custom-type') 91 | }) 92 | -------------------------------------------------------------------------------- /test/forceErrors.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | 6 | import { 7 | createSchema, 8 | Autoform 9 | } from '../src/index' 10 | 11 | const erroring = createSchema('erroring', { 12 | username: { type: 'string' }, 13 | password: { type: 'password' } 14 | }) 15 | 16 | const nested = createSchema('nested', { 17 | errors: { 18 | type: [erroring] 19 | } 20 | }) 21 | 22 | test('Allows you to specify arbitrary errors', async () => { 23 | const errors = { 24 | username: { 25 | message: 'bad username' 26 | }, 27 | password: { 28 | message: 'bad password' 29 | } 30 | } 31 | 32 | const app = mount( 33 | 34 | ) 35 | 36 | expect(app.text()).toMatch(errors.username.message) 37 | expect(app.text()).toMatch(errors.password.message) 38 | }) 39 | 40 | test('Allows you to specify arbitrary errors in nested fields', async () => { 41 | const badText = 'bad nested username' 42 | const errors = { 43 | 'errors.0.username': { 44 | message: badText 45 | } 46 | } 47 | 48 | const app = mount( 49 | 50 | ) 51 | 52 | expect(app.text()).toMatch(badText) 53 | }) 54 | -------------------------------------------------------------------------------- /test/form.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | 6 | import { 7 | createSchema, 8 | Autoform, 9 | setLanguageByName 10 | } from '../src/index' 11 | 12 | const pet = createSchema('pet', { 13 | name: { 14 | type: String, 15 | error: { 16 | maxLength: 4 17 | } 18 | } 19 | }) 20 | 21 | const owner = createSchema('owner', { 22 | pet: { 23 | type: pet, 24 | }, 25 | pets: { 26 | type: [pet] 27 | }, 28 | carSize: { 29 | type: 'select', 30 | options: ['single', 'normal', 'bus driver'] 31 | }, 32 | gender: { 33 | type: 'radios', 34 | options: ['male', 'female', 'both'] 35 | }, 36 | goodBoy: { 37 | type: 'boolean' 38 | } 39 | }) 40 | 41 | test('basic tests in input', () => { 42 | const initially = { 43 | pet: { 44 | name: 'Print Screen' 45 | }, 46 | pets: [{ 47 | name: 'Bobo' 48 | }] 49 | } 50 | 51 | const app = mount( 52 | 53 | ) 54 | 55 | // Some random lazy-fuck tests 56 | const form = app.find('form') 57 | const labels = form.find('label') 58 | const inputs = form.find('input') 59 | const petName = form.find('input[name="pet.name"]') 60 | 61 | expect(form).toHaveLength(1) 62 | expect(labels).toHaveLength(9) 63 | expect(inputs).toHaveLength(6) 64 | 65 | const label = labels.at(1) 66 | expect(label.contains('models.pet.name._field')).toBe(true) 67 | expect(petName.prop('name')).toBe('pet.name') 68 | expect(petName.prop('defaultValue')).toBe('Print Screen') 69 | 70 | const pet0Name = form.find('input[name="pets.0.name"]') 71 | expect(pet0Name).toHaveLength(1) 72 | 73 | const carSizes = form.find('select[name="carSize"] option') 74 | expect(carSizes).toHaveLength(4) 75 | 76 | const genders = form.find('input[name="gender"]') 77 | expect(genders).toHaveLength(3) 78 | 79 | const gender = genders.first() 80 | expect(gender.prop('type')).toBe('radio') 81 | 82 | const good = form.find('input[name="goodBoy"]') 83 | expect(good).toHaveLength(1) 84 | expect(good.prop('type')).toBe('checkbox') 85 | }) 86 | -------------------------------------------------------------------------------- /test/helperText.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | import { act } from 'react-dom/test-utils' 6 | 7 | import { 8 | createSchema, 9 | Autoform, 10 | addTranslations 11 | } from '../src/index' 12 | import { 13 | changeInput, 14 | } from './utils/changeField' 15 | 16 | addTranslations({ 17 | models: { 18 | helping: { 19 | name: { 20 | _helper: 'Translated helper' 21 | } 22 | } 23 | } 24 | }) 25 | 26 | const notHelped = createSchema('helping', { 27 | name: { 28 | type: 'string', 29 | } 30 | }) 31 | 32 | const helped = createSchema('helping', { 33 | name: { 34 | type: 'string', 35 | helperText: 'Schema helper', 36 | onChange: (value, { name, setHelperText }) => { 37 | if (value == 'help') 38 | setHelperText(name, 'Forced by onChange') 39 | } 40 | } 41 | }) 42 | 43 | test('Uses automatically translated helper', async () => { 44 | const app = mount( 45 | 46 | ) 47 | 48 | expect(app.text()).toMatch('Translated helper') 49 | }) 50 | 51 | test('setHelperText over helperText over _helper', async () => { 52 | const app = mount( 53 | 54 | ) 55 | 56 | expect(app.text()).toMatch('Schema helper') 57 | 58 | const name = app.find('input[name="name"]') 59 | 60 | await changeInput(name, 'help') 61 | await app.update() 62 | 63 | expect(app.text()).toMatch('Forced by onChange') 64 | }) 65 | -------------------------------------------------------------------------------- /test/imperative.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import { useRef } from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | import { changeInput } from './utils/changeField' 6 | import { act } from 'react-dom/test-utils' 7 | 8 | import { 9 | createSchema, 10 | Autoform 11 | } from '../src/index' 12 | 13 | const some = createSchema('some', { 14 | name: { type: 'string' }, 15 | }) 16 | 17 | let ref 18 | 19 | const TestAutoform = () => { 20 | ref = useRef() 21 | 22 | return ( 23 | 27 | ) 28 | } 29 | 30 | test('Returns entered values at any time', async () => { 31 | const app = mount( 32 | 33 | ) 34 | 35 | const input = app.find('input[name="name"]') 36 | changeInput(input, 'Yo') 37 | 38 | const doc = ref.current.getValues() 39 | expect(doc).toStrictEqual({ name: 'Yo' }) 40 | }) 41 | 42 | test('Changes values externally', async () => { 43 | const app = mount( 44 | 45 | ) 46 | 47 | ref.current.setValue('name', 'Ey') 48 | 49 | const doc = ref.current.getValues() 50 | expect(doc).toStrictEqual({ name: 'Ey' }) 51 | }) 52 | 53 | test('Resets the form', async () => { 54 | const app = mount( 55 | 56 | ) 57 | 58 | const input = app.find('input[name="name"]') 59 | changeInput(input, 'Delete this') 60 | 61 | act(() => { 62 | ref.current.reset() 63 | }) 64 | 65 | const doc = ref.current.getValues() 66 | expect(doc).toStrictEqual({ name: '' }) 67 | }) 68 | 69 | test('Sets visibility', async () => { 70 | const app = mount( 71 | 72 | ) 73 | 74 | const input = app.find('input[name="name"]') 75 | changeInput(input, 'Invisible') 76 | 77 | act(() => { 78 | ref.current.setVisible('name', false) 79 | }) 80 | const doc = ref.current.getValues() 81 | expect(doc).toStrictEqual({}) 82 | }) 83 | -------------------------------------------------------------------------------- /test/initialization.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { createSchema } from '../src/index' 7 | 8 | import { Autoform } from './utils/buttonHack' 9 | import { createParenter } from './utils/createParenter' 10 | 11 | const parent = createParenter() 12 | 13 | const initial = { 14 | name: 'Father', 15 | childs: [ 16 | { 17 | name: 'One' 18 | }, 19 | { 20 | name: 'Two' 21 | } 22 | ], 23 | child: { 24 | name: 'Uniquer' 25 | }, 26 | boolean: true 27 | } 28 | 29 | test('Sets initial values', () => { 30 | const app = mount( 31 | 32 | ) 33 | 34 | const fatherName = app.find('input[name="name"]') 35 | expect(fatherName.prop('defaultValue')).toBe('Father') 36 | 37 | const numChildren = app.find('.button') 38 | expect(numChildren).toHaveLength(3) 39 | 40 | const child1Name = app.find('input[name="childs.0.name"]') 41 | expect(child1Name.prop('defaultValue')).toBe('One') 42 | 43 | const child2Name = app.find('input[name="childs.1.name"]') 44 | expect(child2Name.prop('defaultValue')).toBe('Two') 45 | 46 | const aloneName = app.find('input[name="child.name"]') 47 | expect(aloneName.prop('defaultValue')).toBe('Uniquer') 48 | }) 49 | 50 | test('Radios have initial values', () => { 51 | const schema = createSchema('schema', { 52 | ops: { 53 | type: 'radios', 54 | options: ['aaa', 'bbb'] 55 | } 56 | }) 57 | 58 | const radioInitial = { 59 | ops: 'bbb' 60 | } 61 | 62 | const app = mount( 63 | 64 | ) 65 | 66 | const input = app.find('#schema-ops-bbb') 67 | expect(input.prop('defaultChecked')).toBe(true) 68 | }) 69 | 70 | test('Checkbox initial values', () => { 71 | const schema = createSchema('schema', { 72 | boolean: { 73 | type: 'boolean' 74 | } 75 | }) 76 | 77 | const booleanInitial = { 78 | boolean: true 79 | } 80 | 81 | const app = mount( 82 | 83 | ) 84 | 85 | const input = app.find('input[name="boolean"]') 86 | expect(input.prop('defaultChecked')).toBe(true) 87 | }) 88 | -------------------------------------------------------------------------------- /test/inputArray.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import './utils/enzymeConfig' 7 | import { changeInput } from './utils/changeField' 8 | 9 | import { Autoform } from './utils/buttonHack' 10 | import { createParenter } from './utils/createParenter' 11 | 12 | const parent = createParenter({ 13 | arrayAdd: { 14 | minChildren: 1, 15 | maxChildren: 2 16 | } 17 | }) 18 | 19 | const findNameInput = (app, idx) => 20 | app.find(`input[name="childs.${idx}.name"]`) 21 | 22 | test('Allows to add in an arrayPanel', async () => { 23 | const app = mount( 24 | 25 | ) 26 | 27 | const button = app.find('button').first() 28 | await act(async () => { 29 | await button.simulate('click') 30 | }) 31 | 32 | const first = findNameInput(app, 0) 33 | changeInput(first, '111') 34 | 35 | const second = findNameInput(app, 1) 36 | changeInput(second, '222') 37 | 38 | const buttons = app.find('button') 39 | expect(buttons).toHaveLength(3) 40 | await act(async () => { 41 | await buttons.at(1).simulate('click') 42 | }) 43 | 44 | const firstNew = findNameInput(app, 0) 45 | expect(firstNew).toHaveLength(1) 46 | 47 | const secondNew = findNameInput(app, 1) 48 | expect(secondNew).toHaveLength(1) 49 | expect(secondNew.instance().value).toBe('222') 50 | 51 | const appText = app.text() 52 | expect(appText).not.toMatch('error.minChildren') 53 | expect(appText).not.toMatch('error.maxChildren') 54 | }) 55 | 56 | test('Complains when lacks children', async () => { 57 | const app = mount( 58 | 59 | ) 60 | 61 | const buttons = app.find('button') 62 | expect(buttons).toHaveLength(2) 63 | await buttons.at(1).simulate('click') 64 | 65 | expect(app.text()).toMatch('error.minChildren') 66 | }) 67 | 68 | test('Complains when has too many children and then calms down', async () => { 69 | const app = mount( 70 | 71 | ) 72 | 73 | const button = app.find('button').first() 74 | await button.simulate('click') 75 | await button.simulate('click') 76 | 77 | expect(app.text()).toMatch('error.maxChildren') 78 | 79 | const buttons = app.find('button') 80 | expect(buttons).toHaveLength(4) 81 | await buttons.at(1).simulate('click') 82 | 83 | expect(app.text()).not.toMatch('error.maxChildren') 84 | expect(app.text()).not.toMatch('error.minChildren') 85 | }) 86 | -------------------------------------------------------------------------------- /test/noAutocomplete.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | 5 | import './utils/enzymeConfig' 6 | import { createSchema } from '../src/index' 7 | 8 | import { Autoform } from './utils/buttonHack' 9 | 10 | const noAutocompleter = createSchema('noAutocompleter', { 11 | allows: { 12 | type: 'string' 13 | }, 14 | doesntAllow: { 15 | type: 'string', 16 | noAutocomplete: true 17 | } 18 | }) 19 | 20 | const checkNoAutocomplete = (app, name, expected) => { 21 | const input = app.find(`input[name="${name}"]`) 22 | if (expected) 23 | expect(input.prop('autoComplete')).toBe('off') 24 | else 25 | expect(input.prop('autoComplete')).toBeUndefined() 26 | } 27 | 28 | test('Everything has autocomplete="off" if set in Autoform', () => { 29 | const app = mount( 30 | 31 | ) 32 | 33 | // console.log('general', app.debug()) 34 | 35 | checkNoAutocomplete(app, 'allows', true) 36 | checkNoAutocomplete(app, 'doesntAllow', true) 37 | }) 38 | 39 | test('Specific inputs have autocomplete="off"', () => { 40 | const app = mount( 41 | 42 | ) 43 | 44 | // console.log('specific', app.debug()) 45 | 46 | checkNoAutocomplete(app, 'allows', false) 47 | checkNoAutocomplete(app, 'doesntAllow', true) 48 | }) 49 | -------------------------------------------------------------------------------- /test/options.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | 6 | import { 7 | createSchema, 8 | Autoform 9 | } from '../src/index' 10 | 11 | test('allows option as object with keys', () => { 12 | const colors = createSchema('colors', { 13 | color: { 14 | type: 'select', 15 | options: [ 16 | { value: 'r', key: 'red' }, 17 | { value: 'g', key: 'green' } 18 | ] 19 | } 20 | }) 21 | 22 | const app = mount( 23 | 24 | ) 25 | 26 | const defOption = app.find('option[value=""]') 27 | expect(defOption).toHaveLength(1) 28 | expect(defOption.contains('models.colors.color._default')).toBe(true) 29 | 30 | const red = app.find('option[value="r"]') 31 | expect(red).toHaveLength(1) 32 | expect(red.contains('models.colors.color.red')).toBe(true) 33 | 34 | const green = app.find('option[value="g"]') 35 | expect(green).toHaveLength(1) 36 | expect(green.contains('models.colors.color.green')).toBe(true) 37 | }) 38 | 39 | test('allows option function that produces object with labels', () => { 40 | const colors = createSchema('colors', { 41 | color: { 42 | type: 'select', 43 | options: () => [ 44 | { value: 'r', label: 'My red' }, 45 | { value: 'g', label: 'Some green' } 46 | ] 47 | } 48 | }) 49 | 50 | const app = mount( 51 | 52 | ) 53 | 54 | const defOption = app.find('option[value=""]') 55 | expect(defOption).toHaveLength(1) 56 | expect(defOption.contains('models.colors.color._default')).toBe(true) 57 | 58 | const red = app.find('option[value="r"]') 59 | expect(red).toHaveLength(1) 60 | expect(red.contains('My red')).toBe(true) 61 | 62 | const green = app.find('option[value="g"]') 63 | expect(green).toHaveLength(1) 64 | expect(green.contains('Some green')).toBe(true) 65 | }) 66 | -------------------------------------------------------------------------------- /test/schema-onChange.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import './utils/enzymeConfig' 5 | import { act } from 'react-dom/test-utils' 6 | 7 | import { 8 | createSchema, 9 | Autoform, 10 | setLanguageByName, 11 | Button, 12 | Wrap 13 | } from '../src/index' 14 | import { 15 | changeInput, 16 | changeSelect, 17 | changeCheckbox 18 | } from './utils/changeField' 19 | 20 | const pet = createSchema('pet', { 21 | name: { 22 | type: String, 23 | required: true, 24 | maxLength: 8, 25 | }, 26 | heads: { 27 | type: Number, 28 | onChange: (value, { arrayControl }) => { 29 | if (value == '42') 30 | arrayControl.add() 31 | if (value == '13') 32 | arrayControl.remove(arrayControl.index) 33 | }, 34 | helperText: 'Enter 42 to add and 13 to remove' 35 | }, 36 | hair: { 37 | type: 'select', 38 | options: ['blue', 'yellow'], 39 | onChange: (value, { name, setHelperText }) => { 40 | setHelperText(name, `Better not choose ${value}`) 41 | } 42 | }, 43 | }) 44 | 45 | const owner = createSchema('owner', { 46 | name: { 47 | type: 'string', 48 | required: true, 49 | onChange: (value, { formHook }) => { 50 | if (value == 'errorsy') { 51 | formHook.setError('height', { 52 | type: 'focus', 53 | message: 'Something something error' 54 | }) 55 | } 56 | } 57 | }, 58 | height: { 59 | type: 'radios', 60 | options: ['tall', 'short'], 61 | onChange: (value, { setValue }) => { 62 | if (value == 'tall') 63 | setValue('name', 'André the Giant') 64 | } 65 | }, 66 | usesHat: { 67 | type: 'boolean', 68 | onChange: (value, { setVisible }) => { 69 | setVisible('hatColor', value) 70 | } 71 | }, 72 | hatColor: { 73 | type: 'select', 74 | options: ['black', 'red'], 75 | initiallyVisible: false 76 | }, 77 | pets: { 78 | type: [pet], 79 | minChildren: 1, 80 | maxChildren: 2 81 | } 82 | }) 83 | 84 | test('onChange: hide according to initiallyVisible and change visibility', async () => { 85 | const app = mount( 86 | 87 | ) 88 | 89 | const usesHat = app.find('input[name="usesHat"]') 90 | expect(usesHat).toHaveLength(1) 91 | 92 | const color = app.find('select[name="hatColor"]') 93 | expect(color).toHaveLength(0) 94 | 95 | await changeCheckbox(usesHat, true) 96 | await app.update() 97 | 98 | const nowColor = app.find('select[name="hatColor"]') 99 | expect(nowColor).toHaveLength(1) 100 | }) 101 | 102 | test('onChange: Change arbitrary input value', async () => { 103 | const app = mount( 104 | 105 | ) 106 | 107 | const name = app.find('input[name="name"]') 108 | expect(name).toHaveLength(1) 109 | 110 | const height = app.find('input[name="height"][value="tall"]') 111 | expect(height).toHaveLength(1) 112 | 113 | await changeInput(height, 'tall') 114 | await app.update() 115 | 116 | expect(name.instance().value).toBe('André the Giant') 117 | }) 118 | 119 | test('onChange: Add and remove array elements', async () => { 120 | const app = mount( 121 | 122 | ) 123 | 124 | const name = app.find('input[name="pets.0.heads"]') 125 | expect(name).toHaveLength(1) 126 | 127 | const buttons = app.find(Button) 128 | expect(buttons).toHaveLength(2) 129 | 130 | await changeInput(name, '42') 131 | await app.update() 132 | 133 | const addedButtons = app.find(Button) 134 | expect(addedButtons).toHaveLength(3) 135 | 136 | await changeInput(name, '13') 137 | await app.update() 138 | 139 | const removedButtons = app.find(Button) 140 | expect(removedButtons).toHaveLength(2) 141 | }) 142 | 143 | test('onChange: Change helper text', async () => { 144 | const app = mount( 145 | 146 | ) 147 | 148 | const hair = app.find('select[name="pets.0.hair"]') 149 | expect(hair).toHaveLength(1) 150 | 151 | await changeSelect(hair, 'blue') 152 | await app.update() 153 | 154 | expect(app.text()).toMatch('Better not choose blue') 155 | }) 156 | 157 | test('onChange: Produce error just because', async () => { 158 | const app = mount( 159 | 160 | ) 161 | 162 | const name = app.find('input[name="name"]') 163 | expect(name).toHaveLength(1) 164 | 165 | await changeInput(name, 'errorsy') 166 | await app.update() 167 | 168 | expect(app.text()).toMatch('Something something error') 169 | }) 170 | -------------------------------------------------------------------------------- /test/submit.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import './utils/enzymeConfig' 7 | import { changeInput, changeSelect, changeCheckbox } from './utils/changeField' 8 | import { 9 | createSchema, 10 | Autoform, 11 | } from '../src/index' 12 | 13 | const custom = createSchema('simple', { 14 | name: { 15 | type: 'string', 16 | }, 17 | numberer: { 18 | type: 'number' 19 | }, 20 | radiator: { 21 | type: 'radios', 22 | options: ['a', 'b'] 23 | }, 24 | selector: { 25 | type: 'select', 26 | options: ['c', 'd'] 27 | }, 28 | booler: { 29 | type: 'boolean' 30 | } 31 | }) 32 | 33 | test('Submits', async () => { 34 | let doSubmit 35 | const wasSubmitted = new Promise((resolve) => { 36 | doSubmit = resolve 37 | }) 38 | const mockSubmit = jest.fn(() => { 39 | doSubmit() 40 | }) 41 | const mockChange = jest.fn() 42 | 43 | const app = mount( 44 | 45 | ) 46 | 47 | const input = app.find('input[name="name"]') 48 | changeInput(input, 'Hello') 49 | 50 | const radiatorB = app.find('#simple-radiator-b') 51 | changeCheckbox(radiatorB, true) 52 | 53 | const numberer = app.find('input[name="numberer"]') 54 | changeInput(numberer, '128') 55 | 56 | const selector = app.find('select[name="selector"]') 57 | changeSelect(selector, 'd') 58 | 59 | const booler = app.find('input[name="booler"]') 60 | changeCheckbox(booler, true) 61 | 62 | const wantedDoc = { 63 | name: 'Hello', 64 | numberer: 128, 65 | radiator: 'b', 66 | selector: 'd', 67 | booler: true 68 | } 69 | 70 | // Test onChanges 71 | expect(mockChange.mock.calls.length).toBe(5) 72 | expect(mockChange.mock.calls[4][0]).toStrictEqual(wantedDoc) 73 | 74 | // Test submit 75 | const Form = app.find(Autoform) 76 | await act(async () => { 77 | await Form.simulate('submit') 78 | }) 79 | 80 | expect.assertions(4) 81 | const { calls } = mockSubmit.mock 82 | return wasSubmitted.then(() => { 83 | expect(calls.length).toBe(1) 84 | expect(calls[0][0]).toStrictEqual(wantedDoc) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/throws.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { shallow, mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import './utils/enzymeConfig' 7 | import { ErrorBoundary, consoleMock } from './utils/ErrorBoundary' 8 | import { 9 | createSchema, 10 | Autoform, 11 | } from '../src/index' 12 | 13 | test('Throws error when you forgot to pass schema', async () => { 14 | const spy = jest.fn() 15 | 16 | const oldError = console.error 17 | console.error = jest.fn() 18 | 19 | const app = mount( 20 | 21 | 22 | 23 | ) 24 | console.error = oldError 25 | 26 | const { calls } = spy.mock 27 | 28 | expect(app.state()).toHaveProperty('hasError', true) 29 | expect(calls).toHaveLength(1) 30 | expect(calls[0][0].message).toBe( 31 | ' was rendered without schema.' 32 | ) 33 | }) 34 | 35 | test('Throws error when field lacks type', async () => { 36 | const spy = jest.fn() 37 | 38 | const schema = createSchema('fail', { 39 | notype: {} 40 | }) 41 | 42 | const oldError = console.error 43 | console.error = jest.fn() 44 | 45 | const app = mount( 46 | 47 | 48 | 49 | ) 50 | console.error = oldError 51 | 52 | const { calls } = spy.mock 53 | 54 | expect(app.state()).toHaveProperty('hasError', true) 55 | expect(calls).toHaveLength(1) 56 | expect(calls[0][0]).toBe( 57 | 'Schema "fail" has field "notype" that lacks type description.' 58 | ) 59 | }) 60 | 61 | test('Throws error when type doesn\'t exist in skin', async () => { 62 | const spy = jest.fn() 63 | 64 | const schema = createSchema('fail', { 65 | notype: { type: 'some-shit' } 66 | }) 67 | 68 | const oldError = console.error 69 | console.error = jest.fn() 70 | 71 | const app = mount( 72 | 73 | 74 | 75 | ) 76 | console.error = oldError 77 | 78 | const { calls } = spy.mock 79 | 80 | expect(app.state()).toHaveProperty('hasError', true) 81 | expect(calls).toHaveLength(1) 82 | expect(calls[0][0]).toBe( 83 | 'Schema "fail" has field "notype" with type "some-shit" that doesn\'t exist in skin.' 84 | ) 85 | }) 86 | -------------------------------------------------------------------------------- /test/translation.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | tr, 3 | setLanguage, 4 | addTranslations, 5 | stringExists 6 | } from '../src/translate' 7 | 8 | setLanguage({ 9 | single: 'single value', 10 | variable: 'test __value__', 11 | reference: 'test @@ref@@', 12 | ref: 'reference', 13 | subkey: { 14 | _: 'root value', 15 | sub1: 'sub1' 16 | } 17 | }) 18 | 19 | addTranslations({ 20 | added: 'added string' 21 | }) 22 | 23 | test('Finds string given its id', () => { 24 | const str = tr('single') 25 | expect(str).toEqual('single value') 26 | }) 27 | 28 | test('Substitutes variable given its id and data', () => { 29 | const str = tr('variable', { value: 'variable' }) 30 | expect(str).toEqual('test variable') 31 | }) 32 | 33 | test('Finds referenced', () => { 34 | const str = tr('reference') 35 | expect(str).toEqual('test reference') 36 | }) 37 | 38 | test('Finds key when there is no leaf', () => { 39 | const str = tr('single.user') 40 | expect(str).toEqual('single value') 41 | }) 42 | 43 | test('Finds subkey', () => { 44 | const str = tr('subkey.sub1') 45 | expect(str).toEqual('sub1') 46 | }) 47 | 48 | test('Finds subkey when there is no leaf but subkey has root value', () => { 49 | const str = tr('subkey.sub2') 50 | expect(str).toEqual('root value') 51 | }) 52 | 53 | test('Returns string as is when there is no variable (for compatibility)', () => { 54 | const str = tr('variable') 55 | expect(str).toEqual('test __value__') 56 | }) 57 | 58 | test('Returns id when string is not found', () => { 59 | const str = tr('some stuff') 60 | expect(str).toEqual('some stuff') 61 | }) 62 | 63 | test('Returns added translations', () => { 64 | const str = tr('added') 65 | expect(str).toEqual('added string') 66 | }) 67 | 68 | test('See if string exists', () => { 69 | const exists = stringExists('added') 70 | expect(exists).toEqual(true) 71 | }) 72 | 73 | test('See if string doesn\'t not not exist', () => { 74 | const exists = stringExists('inventedShit') 75 | expect(exists).toEqual(false) 76 | }) 77 | -------------------------------------------------------------------------------- /test/utils/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | // https://enzymejs.github.io/enzyme/docs/api/ShallowWrapper/simulateError.html 2 | 3 | import React from 'react' 4 | 5 | function Something() { 6 | // this is just a placeholder 7 | return null; 8 | } 9 | 10 | export class ErrorBoundary extends React.Component { 11 | static getDerivedStateFromError(error) { 12 | return { 13 | hasError: true, 14 | }; 15 | } 16 | 17 | constructor(props) { 18 | super(props); 19 | this.state = { hasError: false }; 20 | } 21 | 22 | componentDidCatch(error, info) { 23 | const { spy } = this.props; 24 | spy(error, info); 25 | } 26 | 27 | render() { 28 | const { children } = this.props; 29 | const { hasError } = this.state; 30 | return ( 31 | 32 | {hasError ? 'Error' : children} 33 | 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/utils/_mutationObserverHack.js: -------------------------------------------------------------------------------- 1 | global.MutationObserver = class { 2 | constructor(callback) {} 3 | disconnect() {} 4 | observe(element, initObject) {} 5 | } 6 | -------------------------------------------------------------------------------- /test/utils/buttonHack.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Autoform as AutoformRHFA } from '../../src' 3 | 4 | // To find buttons 5 | const styles = { 6 | button: 'button' 7 | } 8 | 9 | export const Autoform = props => 10 | 14 | -------------------------------------------------------------------------------- /test/utils/changeField.js: -------------------------------------------------------------------------------- 1 | // input.props() would be the closest to input attributes, 2 | // that doesn't seem to be in the simulated input.instance() 3 | 4 | 5 | export function changeInput(input, value, attr = 'value') { 6 | // FIXME I don't know why I do this, but it looks 7 | // like input.simulate() is not updating dom as a 8 | // browser would do or there's something I don't 9 | // understand 10 | input.instance()[attr] = value 11 | 12 | return input.simulate('change', { 13 | target: { 14 | ...input.props(), 15 | [attr]: value, 16 | value 17 | } 18 | }) 19 | } 20 | 21 | export const changeSelect = changeInput 22 | 23 | export const changeCheckbox = (input, value) => 24 | changeInput(input, value, 'checked') 25 | -------------------------------------------------------------------------------- /test/utils/createParenter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createSchema } from '../../src/index' 3 | 4 | export const createParenter = ({ 5 | everythingRequired, 6 | arrayAdd 7 | } = {}) => { 8 | const child = createSchema('childer', { 9 | name: { 10 | type: 'string', 11 | required: everythingRequired 12 | } 13 | }) 14 | 15 | return createSchema('parenter', { 16 | name: { 17 | type: 'string', 18 | required: everythingRequired 19 | }, 20 | childs: { 21 | type: [child], 22 | required: everythingRequired, 23 | ...arrayAdd 24 | }, 25 | child: { 26 | type: child, 27 | required: everythingRequired 28 | } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /test/utils/createSubmitMocks.js: -------------------------------------------------------------------------------- 1 | export const createSubmitMocks = () => { 2 | let doSubmit 3 | const wasSubmitted = new Promise((resolve, reject) => { 4 | doSubmit = resolve 5 | }) 6 | const mockSubmit = jest.fn(() => { 7 | doSubmit() 8 | }) 9 | 10 | return { wasSubmitted, mockSubmit } 11 | } 12 | -------------------------------------------------------------------------------- /test/utils/enzymeConfig.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme' 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17' 3 | import './_mutationObserverHack.js' 4 | 5 | configure({ adapter: new Adapter() }) 6 | -------------------------------------------------------------------------------- /test/validations.react.test.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register' 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { act } from 'react-dom/test-utils' 5 | 6 | import './utils/enzymeConfig' 7 | import { changeInput } from './utils/changeField' 8 | import { 9 | createSchema, 10 | Autoform, 11 | tr, 12 | addTranslations, 13 | InputWrap 14 | } from '../src/index' 15 | import { createSubmitMocks } from './utils/createSubmitMocks' 16 | import { createParenter } from './utils/createParenter' 17 | 18 | addTranslations({ 19 | error: { 20 | contains: 'Wrong, should be __pattern__', 21 | maxWeight: 'Too heavy' 22 | } 23 | }) 24 | 25 | const custom = createSchema('validator', { 26 | name: { 27 | type: 'string', 28 | pattern: { 29 | value: /abcd/, 30 | message: fieldSchema => tr('error.contains', { 31 | pattern: fieldSchema.pattern.value.toString() 32 | }) 33 | } 34 | }, 35 | weight: { 36 | type: 'number', 37 | validate: { 38 | value: value => value < 100 || tr('error.maxWeight') 39 | } 40 | } 41 | }) 42 | 43 | test('Passes with pattern', async () => { 44 | const { wasSubmitted, mockSubmit } = createSubmitMocks() 45 | 46 | const app = mount( 47 | 48 | ) 49 | 50 | const input = app.find('input[name="name"]') 51 | changeInput(input, 'abcd') 52 | 53 | const inputWeight = app.find('input[name="weight"]') 54 | changeInput(inputWeight, '15') 55 | 56 | const form = app.find(Autoform) 57 | await act(async () => { 58 | await form.simulate('submit') 59 | }) 60 | 61 | expect.assertions(1) 62 | const { calls } = mockSubmit.mock 63 | return wasSubmitted.then(() => { 64 | expect(calls[0][0]).toStrictEqual({ name: 'abcd', weight: 15 }) 65 | }) 66 | }) 67 | 68 | test('Calls onErrors with message when pattern is bad', async () => { 69 | const { wasSubmitted, mockSubmit } = createSubmitMocks() 70 | 71 | const app = mount( 72 | 73 | ) 74 | 75 | const input = app.find('input[name="name"]') 76 | changeInput(input, 'abcx') 77 | 78 | const form = app.find(Autoform) 79 | await act(async () => { 80 | await form.simulate('submit') 81 | }) 82 | 83 | expect.assertions(1) 84 | const { calls } = mockSubmit.mock 85 | return wasSubmitted.then(() => { 86 | expect(calls[0][0]).toMatchObject({ 87 | name: { 88 | type: 'pattern', 89 | message: 'Wrong, should be /abcd/' 90 | } 91 | }) 92 | }) 93 | }) 94 | 95 | test('Passes with validation function', async () => { 96 | const { wasSubmitted, mockSubmit } = createSubmitMocks() 97 | 98 | const app = mount( 99 | 100 | ) 101 | 102 | const name = app.find('input[name="name"]') 103 | changeInput(name, 'abcd') 104 | const weight = app.find('input[name="weight"]') 105 | changeInput(weight, '15') 106 | 107 | const form = app.find(Autoform) 108 | await act(async () => { 109 | await form.simulate('submit') 110 | }) 111 | 112 | expect.assertions(1) 113 | 114 | const { calls } = mockSubmit.mock 115 | return wasSubmitted.then(() => { 116 | expect(calls[0][0]).toStrictEqual({ name: 'abcd', weight: 15 }) 117 | }) 118 | }) 119 | 120 | test('Fails successfuly with validation function', async () => { 121 | const { wasSubmitted, mockSubmit } = createSubmitMocks() 122 | 123 | const app = mount( 124 | 125 | ) 126 | 127 | const name = app.find('input[name="name"]') 128 | changeInput(name, 'abcd') 129 | const weight = app.find('input[name="weight"]') 130 | changeInput(weight, '150') 131 | 132 | const form = app.find(Autoform) 133 | await act(async () => { 134 | await form.simulate('submit') 135 | }) 136 | 137 | expect.assertions(1) 138 | 139 | const { calls } = mockSubmit.mock 140 | return wasSubmitted.then(() => { 141 | expect(calls[0][0]).toMatchObject({ weight: { message: 'Too heavy' } }) 142 | }) 143 | }) 144 | 145 | test('Fails successfuly with nested components', async () => { 146 | const { wasSubmitted, mockSubmit } = createSubmitMocks() 147 | 148 | const parenter = createParenter({ everythingRequired: true }) 149 | 150 | const app = mount( 151 | 152 | ) 153 | 154 | const form = app.find(Autoform) 155 | await act(async () => { 156 | await form.simulate('submit') 157 | }) 158 | 159 | expect.assertions(7) 160 | 161 | const { calls } = mockSubmit.mock 162 | return wasSubmitted.then(() => { 163 | const doc = calls[0][0] 164 | expect(doc.name.message).toBe('error.required') 165 | expect(doc.childs).toHaveLength(1) 166 | expect(doc.childs[0].name.message).toBe('error.required') 167 | expect(doc.child.name.message).toBe('error.required') 168 | 169 | const fields = [ 170 | 'name', 171 | 'childs.0.name', 172 | 'child.name' 173 | ] 174 | 175 | const wraps = app.find(InputWrap) 176 | wraps.forEach(wrap => { 177 | const name = wrap.prop('name') 178 | if (fields.includes(name)) { 179 | const contents = wrap.text() 180 | expect(contents).toMatch(/error.required/) 181 | } 182 | }) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { merge } = require('webpack-merge') 3 | const package = require('./package.json') 4 | 5 | const commonConfig = ({ mode, minimize }) => { 6 | return { 7 | mode, 8 | devtool: 'source-map', 9 | resolve: { 10 | extensions: ['.js', '.jsx'] 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(js|jsx)$/, 16 | exclude: /^node_modules/, 17 | loader: 'babel-loader', 18 | }, 19 | ], 20 | }, 21 | optimization: { minimize } 22 | } 23 | } 24 | 25 | const webConfig = ({ minAdd }) => { 26 | const webFilename = `${package.name}${minAdd}.js` 27 | 28 | return { 29 | entry: [ 30 | './src/index.js', 31 | ], 32 | output: { 33 | path: path.join(__dirname, 'dist'), 34 | filename: webFilename, 35 | libraryTarget: 'umd', 36 | }, 37 | externals: { 38 | 'react': 'react' 39 | } 40 | } 41 | } 42 | 43 | const reactBaseConfig = ({ minAdd }) => { 44 | const baseFilename = `base${minAdd}.js` 45 | 46 | return { 47 | entry: [ 48 | './src/index_base.js', 49 | ], 50 | output: { 51 | path: path.join(__dirname, 'dist'), 52 | filename: baseFilename, 53 | libraryTarget: 'umd', 54 | }, 55 | externals: { 56 | 'react': 'react', 57 | 'react-native': 'react-native', 58 | 'react-hook-form': 'react-hook-form' 59 | }, 60 | } 61 | } 62 | 63 | module.exports = (env = {}) => { 64 | const { mode } = env 65 | const isBase = env.buildtype == 'base' 66 | const isProduction = mode == 'production' 67 | const minimize = env.minify == 'true' 68 | const minAdd = isProduction ? '.min' : '' 69 | 70 | const common = commonConfig({ mode, minimize }) 71 | const mergeWith = isBase ? 72 | reactBaseConfig({ minAdd }) : webConfig({ minAdd }) 73 | 74 | return merge([ common, mergeWith ]) 75 | } 76 | --------------------------------------------------------------------------------