├── .babelrc ├── .eslintrc.yml ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .yarn └── patches │ └── @4c-rollout-npm-4.0.2-ab2b6d0bab.patch ├── .yarnrc.yml ├── CHANGELOG.md ├── License.txt ├── README.md ├── assets ├── bowtie-fav.svg ├── triangle.svg ├── tux-outline.svg ├── tux-simple.svg ├── tux.svg ├── tux2.svg └── tux3.svg ├── favicon.ico ├── package.json ├── package.tgz ├── src ├── BindingContext.tsx ├── Contexts.tsx ├── Errors.ts ├── Field.tsx ├── FieldArray.tsx ├── Form.tsx ├── Message.tsx ├── NestedForm.tsx ├── Reset.tsx ├── Submit.tsx ├── Summary.tsx ├── config.ts ├── errorManager.ts ├── globals.d.ts ├── index.ts ├── types.ts ├── useBinding.tsx ├── useErrors.ts ├── useField.ts ├── useFieldArray.ts ├── useFieldSchema.tsx ├── useForm.ts ├── useFormReset.ts ├── useFormSubmit.ts ├── useFormValues.ts ├── useTouched.ts └── utils │ ├── errToJSON.ts │ ├── isNativeType.ts │ ├── memoize-one.ts │ ├── notify.ts │ ├── paths.ts │ ├── uniqBy.ts │ ├── uniqMessage.ts │ └── updateIn.ts ├── test ├── .eslintrc.yml ├── ErrorUtils.spec.ts ├── Field.spec.tsx ├── FieldArray.spec.tsx ├── Form.spec.tsx ├── Message.spec.tsx ├── NestedForm.spec.tsx ├── Reset.spec.tsx ├── Submit.spec.tsx ├── bindings.test.tsx ├── paths.spec.tsx ├── setup.ts ├── tsconfig.json ├── types-check.tsx └── update.test.ts ├── tsconfig.json ├── vite.config.mts ├── www ├── .eslintrc.js ├── favicon.ico ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── package.json ├── src │ ├── @docpocalypse │ │ └── gatsby-theme │ │ │ └── components │ │ │ ├── ComponentImport.js │ │ │ ├── Navbar.js │ │ │ └── SideNavigation.js │ ├── bow-tie.svg │ ├── bowtie-main.svg │ ├── components │ │ ├── Callout.js │ │ ├── FormWithErrors.js │ │ ├── FormWithResult.js │ │ ├── IconButton.js │ │ ├── Logo.js │ │ ├── Pre.js │ │ └── Result.js │ ├── demos │ │ ├── .eslintrc │ │ ├── Form.js │ │ └── intro.js │ ├── examples │ │ ├── .gitkeep │ │ ├── .prettierrc │ │ ├── Field.mdx │ │ ├── FieldArray.mdx │ │ ├── Form.mdx │ │ ├── Message.mdx │ │ ├── NestedForm.mdx │ │ ├── Reset.mdx │ │ ├── Submit.mdx │ │ ├── useField.mdx │ │ ├── useFieldArray.mdx │ │ └── useFormValues.mdx │ ├── fakeSaveToServer.js │ ├── pages │ │ ├── controllables.mdx │ │ ├── getting-started.mdx │ │ ├── index.js │ │ └── migration-v2.mdx │ ├── schema.js │ ├── scope.js │ └── styles │ │ └── global.css ├── static │ └── favicon.ico ├── tailwind.config.js └── yarn.lock └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | ["@babel/preset-react", { "runtime": "automatic" }] 5 | ], 6 | "env": { 7 | "cjs": { 8 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - jason/react 3 | - 4catalyzer-typescript 4 | globals: 5 | Promise: true 6 | rules: 7 | prefer-const: off 8 | func-call-spacing: 'off' 9 | '@typescript-eslint/naming-convention': off 10 | '@typescript-eslint/member-delimiter-style': off 11 | '@typescript-eslint/ban-ts-comment': 'off' 12 | '@typescript-eslint/explicit-module-boundary-types': 'off' 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /cjs 3 | /www/.cache 4 | /www/public 5 | 6 | # Logs 7 | logs 8 | *.log 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # Commenting this out is preferred by some people, see 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 30 | node_modules 31 | 32 | # Users Environment Variables 33 | .lock-wscript 34 | 35 | # ========================= 36 | # Operating System Files 37 | # ========================= 38 | 39 | # OSX 40 | # ========================= 41 | 42 | .DS_Store 43 | .AppleDouble 44 | .LSOverride 45 | 46 | # Thumbnails 47 | ._* 48 | 49 | # Files that might appear on external disk 50 | .Spotlight-V100 51 | .Trashes 52 | 53 | # Directories potentially created on remote AFP share 54 | .AppleDB 55 | .AppleDesktop 56 | Network Trash Folder 57 | Temporary Items 58 | .apdisk 59 | 60 | # Windows 61 | # ========================= 62 | 63 | # Windows image file caches 64 | Thumbs.db 65 | ehthumbs.db 66 | 67 | # Folder config file 68 | Desktop.ini 69 | 70 | # Recycle Bin used on file shares 71 | $RECYCLE.BIN/ 72 | 73 | # Windows Installer files 74 | *.cab 75 | *.msi 76 | *.msm 77 | *.msp 78 | 79 | # Windows shortcuts 80 | *.lnk 81 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch: -------------------------------------------------------------------------------- 1 | diff --git a/command.js b/command.js 2 | index 9608bffb4a52e5b8066e263d1286420ae92988bb..86ca58d70f315dae45fc739707dcff07a99a2be4 100644 3 | --- a/command.js 4 | +++ b/command.js 5 | @@ -292,8 +292,7 @@ const handlerImpl = async (argv) => { 6 | task: () => 7 | exec('yarn', [ 8 | 'install', 9 | - '--frozen-lockfile', 10 | - '--production=false', 11 | + '--immutable', 12 | ]), 13 | } 14 | : { 15 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.7.1](https://github.com/jquense/react-formal/compare/v2.7.0...v2.7.1) (2023-07-11) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * export useForm ([#184](https://github.com/jquense/react-formal/issues/184)) ([e3c1f53](https://github.com/jquense/react-formal/commit/e3c1f539bab1485d8d53e25cb247074945fc8c5e)) 7 | * react 18 array helpers complaining about missing schema ([95518ba](https://github.com/jquense/react-formal/commit/95518ba51559a11010997b6c927d4f647852309f)) 8 | 9 | 10 | 11 | 12 | 13 | # [2.7.0](https://github.com/jquense/react-formal/compare/v2.6.0...v2.7.0) (2022-05-05) 14 | 15 | 16 | ### Features 17 | 18 | * return true/false from submit ([ebce1d8](https://github.com/jquense/react-formal/commit/ebce1d89f33fa820d917d3a904fa5d7a6ecea2aa)) 19 | 20 | 21 | 22 | 23 | 24 | # [2.6.0](https://github.com/jquense/react-formal/compare/v2.5.0...v2.6.0) (2022-04-26) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * root path validations ([7ef990d](https://github.com/jquense/react-formal/commit/7ef990d7e1cfead52101305a5f49b143629a51d1)) 30 | 31 | 32 | ### Features 33 | 34 | * reset errors on Reset as well as value ([f148ddf](https://github.com/jquense/react-formal/commit/f148ddf974c85eb457c03d936a500b4d94cb728c)) 35 | 36 | 37 | 38 | 39 | 40 | # [2.5.0](https://github.com/jquense/react-formal/compare/v2.4.1...v2.5.0) (2022-03-14) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * warning error processing empty errors ([303e10c](https://github.com/jquense/react-formal/commit/303e10c9d5ab241d33266e0456ee9950bade9d8c)) 46 | 47 | 48 | ### Features 49 | 50 | * **useFormReset:** Add form reset hook and component ([#181](https://github.com/jquense/react-formal/issues/181)) ([9d81859](https://github.com/jquense/react-formal/commit/9d81859a2ffd271599f7f3f58b524b2cde57b48f)) 51 | 52 | 53 | 54 | 55 | 56 | ## [2.4.1](https://github.com/jquense/react-formal/compare/v2.4.0...v2.4.1) (2022-01-18) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * pass props to inner type if a field is invalid ([#180](https://github.com/jquense/react-formal/issues/180)) ([29122ea](https://github.com/jquense/react-formal/commit/29122eafc33239f1c8bf87ebe10a97354b1ed459)) 62 | 63 | 64 | 65 | 66 | 67 | # [2.4.0](https://github.com/jquense/react-formal/compare/v2.3.0...v2.4.0) (2022-01-15) 68 | 69 | 70 | ### Features 71 | 72 | * clean up contexts and use observed bits ([9e7a2c7](https://github.com/jquense/react-formal/commit/9e7a2c7240b24dbe7db57bb4d5d0b63a7dace293)) 73 | 74 | 75 | 76 | 77 | 78 | # [2.3.0](https://github.com/jquense/react-formal/compare/v2.2.3...v2.3.0) (2021-12-02) 79 | 80 | 81 | ### Features 82 | 83 | * bump deps and remove index sig from prop type ([fed8e54](https://github.com/jquense/react-formal/commit/fed8e5491b3a3af3903238506741759ac7c0a061)) 84 | 85 | 86 | 87 | 88 | 89 | ## [2.2.3](https://github.com/jquense/react-formal/compare/v2.2.2...v2.2.3) (2021-07-19) 90 | 91 | 92 | ### Bug Fixes 93 | 94 | * stop propagation on form submit ([#178](https://github.com/jquense/react-formal/issues/178)) ([de88a01](https://github.com/jquense/react-formal/commit/de88a0194ecef485a48e75028f2c86fa81650612)) 95 | 96 | 97 | 98 | 99 | 100 | ## [2.2.2](https://github.com/jquense/react-formal/compare/v2.2.1...v2.2.2) (2021-01-25) 101 | 102 | 103 | ### Bug Fixes 104 | 105 | * wider react type range ([2d9dfb2](https://github.com/jquense/react-formal/commit/2d9dfb255c444ff6576b333a2316b9b42aa843ac)) 106 | 107 | 108 | 109 | 110 | 111 | ## [2.2.1](https://github.com/jquense/react-formal/compare/v2.2.0...v2.2.1) (2021-01-15) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * bad cjs import ([ff990a3](https://github.com/jquense/react-formal/commit/ff990a34ae3203b91af48273830a9c032335e431)) 117 | 118 | 119 | 120 | 121 | 122 | # [2.2.0](https://github.com/jquense/react-formal/compare/v2.1.3...v2.2.0) (2020-12-07) 123 | 124 | 125 | ### Features 126 | 127 | * bump yup types ([daa8419](https://github.com/jquense/react-formal/commit/daa841995df8bcedb0536f1fa424d82928d2b96f)) 128 | 129 | 130 | 131 | 132 | 133 | ## [2.1.3](https://github.com/jquense/react-formal/compare/v2.1.2...v2.1.3) (2020-11-20) 134 | 135 | 136 | ### Bug Fixes 137 | 138 | * **types:** looser value typing ([52e92b2](https://github.com/jquense/react-formal/commit/52e92b2)) 139 | 140 | 141 | 142 | 143 | 144 | ## [2.1.2](https://github.com/jquense/react-formal/compare/v2.1.1...v2.1.2) (2020-08-06) 145 | 146 | 147 | ### Bug Fixes 148 | 149 | * Clear errors after validation on submit ([#174](https://github.com/jquense/react-formal/issues/174)) ([7d12f5f](https://github.com/jquense/react-formal/commit/7d12f5f)) 150 | 151 | 152 | 153 | 154 | 155 | ## [2.1.1](https://github.com/jquense/react-formal/compare/v2.1.0...v2.1.1) (2020-07-27) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * dont validate on submit ([#173](https://github.com/jquense/react-formal/issues/173)) ([fca9c63](https://github.com/jquense/react-formal/commit/fca9c63)) 161 | 162 | 163 | 164 | 165 | 166 | # [2.1.0](https://github.com/jquense/react-formal/compare/v2.0.0...v2.1.0) (2020-05-19) 167 | 168 | 169 | ### Features 170 | 171 | * bump type defs ([549b880](https://github.com/jquense/react-formal/commit/549b880574f086894c39f9b7d9e766c6861c42c0)) 172 | 173 | 174 | 175 | 176 | 177 | # [2.0.0-0](https://github.com/jquense/react-formal/compare/v1.0.0...v2.0.0-0) (2019-03-21) 178 | 179 | 180 | ### Features 181 | 182 | * add useField and useFieldArray ([c224829](https://github.com/jquense/react-formal/commit/c224829)) 183 | 184 | 185 | ### BREAKING CHANGES 186 | 187 | * bump min react version 188 | * Field and FieldArray function children are passed props and meta as seperate arguments 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jason Quense 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-formal 2 | [![npm version](https://img.shields.io/npm/v/react-formal.svg?style=flat-square)](https://www.npmjs.com/package/react-formal) 3 | 4 | Better form validation and value management for React. Provides __minimal__ wiring while also allowing for complete input flexibility. 5 | 6 | Built on [yup](https://github.com/jquense/yup) and [react-input-message](https://github.com/jquense/react-input-message). 7 | 8 | ## Install 9 | 10 | ```sh 11 | npm i -S react-formal yup 12 | ``` 13 | 14 | __(don't like the yup but like how the form works? Try: [topeka](https://github.com/jquense/topeka))__ 15 | 16 | ### Use 17 | 18 | __For more complete api documentations, live examples, and getting started guide check out the [documentation site](http://jquense.github.io/react-formal).__ 19 | 20 | `react-formal` uses a [yup](https://github.com/jquense/yup) schema to update and validate form values. It treats the `form` like an input (representing an object) with a `value` and `onChange`. The `form` can be controlled or uncontrolled as well, just like a normal React input. 21 | 22 | ```js 23 | var yup = require('yup') 24 | , Form = require('react-formal') 25 | 26 | var modelSchema = yup.object({ 27 | name: yup.object({ 28 | first: yup.string().required('Name is required'), 29 | last: yup.string().required('Name is required') 30 | }), 31 | dateOfBirth: yup.date() 32 | .max(new Date(), 'You can be born in the future!') 33 | }) 34 | 35 | // ...in a component 36 | render() { 37 | return ( 38 |
this.setState({ model })} 42 | > 43 |
44 | Personal Details 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | Submit 56 |
57 | ) 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /assets/bowtie-fav.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/tux-outline.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | 18 | 19 | 22 | 23 | 24 | 27 | 28 | 29 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /assets/tux-simple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /assets/tux.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | 15 | 18 | 19 | 22 | 23 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /assets/tux2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jquense/react-formal/095e4f7c4a18123ef8dc0a59a8fef38ed82019ed/favicon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-formal", 3 | "version": "3.0.0-rc.1", 4 | "description": "Classy HTML form management for React", 5 | "author": { 6 | "name": "Jason Quense", 7 | "email": "monastic.panic@gmail.com" 8 | }, 9 | "homepage": "http://jquense.github.io/react-formal/", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jquense/react-formal.git" 13 | }, 14 | "license": "MIT", 15 | "sideEffects": false, 16 | "files": [ 17 | "lib", 18 | "cjs", 19 | "CHANGELOG.md" 20 | ], 21 | "type": "module", 22 | "exports": { 23 | "./package.json": "./package.json", 24 | ".": { 25 | "require": { 26 | "types": "./cjs/index.d.ts", 27 | "default": "./cjs/index.js" 28 | }, 29 | "import": { 30 | "types": "./lib/index.d.ts", 31 | "default": "./lib/index.js" 32 | } 33 | } 34 | }, 35 | "keywords": [ 36 | "react-formal", 37 | "react", 38 | "form", 39 | "forms", 40 | "inputs", 41 | "validator", 42 | "schema", 43 | "validation", 44 | "react-component", 45 | "yup" 46 | ], 47 | "scripts": { 48 | "test": "vitest run", 49 | "tdd": "vitest", 50 | "lint": "eslint src", 51 | "docs": "yarn --cwd www start", 52 | "docs:deploy": "yarn --cwd www deploy", 53 | "build:esm": "babel src --out-dir lib --delete-dir-on-start --extensions '.ts,.tsx' --ignore='**/*.d.ts'", 54 | "build:esm:types": "tsc -p . --emitDeclarationOnly --declaration --outDir lib", 55 | "build:cjs": "babel src --out-dir cjs --env-name cjs --delete-dir-on-start --extensions '.ts,.tsx' --ignore='**/*.d.ts' && echo '{\"type\": \"commonjs\"}' > cjs/package.json", 56 | "build:cjs:types": "tsc -p . --emitDeclarationOnly --declaration --outDir cjs", 57 | "build": "yarn build:esm && yarn build:esm:types && yarn build:cjs && yarn build:cjs:types", 58 | "prepare": "npm run build", 59 | "deploy-docs": "yarn --cwd www build --prefix-paths && gh-pages -d www/public", 60 | "release": "rollout" 61 | }, 62 | "prettier": { 63 | "singleQuote": true, 64 | "trailingComma": "all", 65 | "quoteProps": "consistent" 66 | }, 67 | "publishConfig": { 68 | "access": "public" 69 | }, 70 | "release": { 71 | "conventionalCommits": true 72 | }, 73 | "peerDependencies": { 74 | "react": ">=18.0.0" 75 | }, 76 | "dependencies": { 77 | "@restart/hooks": "^0.6.2", 78 | "prop-types": "^15.7.2", 79 | "property-expr": "^2.0.4", 80 | "shallowequal": "^1.1.0", 81 | "uncontrollable": "^9.0.0", 82 | "yup": ">=1.0.0" 83 | }, 84 | "devDependencies": { 85 | "@4c/cli": "^4.0.4", 86 | "@4c/rollout": "patch:@4c/rollout@npm%3A4.0.2#~/.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch", 87 | "@4c/tsconfig": "^0.4.0", 88 | "@babel/cli": "7.26.4", 89 | "@babel/core": "7.26.0", 90 | "@babel/plugin-transform-modules-commonjs": "^7.26.3", 91 | "@babel/preset-react": "^7.26.3", 92 | "@babel/preset-typescript": "^7.26.0", 93 | "@testing-library/dom": "^10.4.0", 94 | "@testing-library/react": "^16.1.0", 95 | "@testing-library/user-event": "^14.5.2", 96 | "@types/react": "^19.0.2", 97 | "@types/react-dom": "^19.0.2", 98 | "@typescript-eslint/eslint-plugin": "^8.19.0", 99 | "@typescript-eslint/parser": "^8.19.0", 100 | "babel-eslint": "^10.1.0", 101 | "babel-preset-jason": "^6.3.0", 102 | "cherry-pick": "^0.5.0", 103 | "enzyme": "^3.11.0", 104 | "enzyme-adapter-react-16": "^1.15.6", 105 | "eslint": "^8.3.0", 106 | "eslint-config-4catalyzer-typescript": "^3.2.0", 107 | "eslint-config-jason": "^8.2.2", 108 | "eslint-import-resolver-webpack": "^0.13.2", 109 | "eslint-plugin-import": "^2.25.3", 110 | "eslint-plugin-react": "^7.27.1", 111 | "eslint-plugin-react-hooks": "^4.3.0", 112 | "gh-pages": "^3.2.3", 113 | "jsdom": "^25.0.1", 114 | "prettier": "^2.5.0", 115 | "react": "^19.0.0", 116 | "react-dom": "^19.0.0", 117 | "react-tackle-box": "^2.1.0", 118 | "react-widgets": "^5.5.1", 119 | "rimraf": "^3.0.2", 120 | "typescript": "^5.7.2", 121 | "vitest": "^2.1.8" 122 | }, 123 | "bugs": { 124 | "url": "https://github.com/jquense/react-formal/issues" 125 | }, 126 | "packageManager": "yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728", 127 | "resolutions": { 128 | "@4c/rollout@npm:^4.0.2": "patch:@4c/rollout@npm%3A4.0.2#~/.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch" 129 | }, 130 | "stableVersion": "2.7.1" 131 | } 132 | -------------------------------------------------------------------------------- /package.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jquense/react-formal/095e4f7c4a18123ef8dc0a59a8fef38ed82019ed/package.tgz -------------------------------------------------------------------------------- /src/BindingContext.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import expr from 'property-expr'; 4 | import useUpdatedRef from '@restart/hooks/useUpdatedRef'; 5 | import React, { 6 | useCallback, 7 | useMemo, 8 | useContext, 9 | useRef, 10 | useState, 11 | useLayoutEffect, 12 | SyntheticEvent, 13 | } from 'react'; 14 | import updateIn from './utils/updateIn.js'; 15 | import { MapToValue } from './useBinding.js'; 16 | 17 | export type Mapper = (input: TIn) => TOut; 18 | 19 | type BindingValue = Record | unknown[]; 20 | 21 | export const formGetter = (path: string, model: any) => 22 | path ? expr.getter(path, true)(model || {}) : model; 23 | 24 | export function formSetter( 25 | path: string, 26 | value: TValue | undefined, 27 | fieldValue: unknown, 28 | ) { 29 | return updateIn(value, path, fieldValue); 30 | } 31 | 32 | type BindingContextValue = { 33 | getValue(path: Mapper | keyof T): T; 34 | getSchemaForPath: (path: string) => any; 35 | updateBindingValue(path: MapToValue, args: any[]): void; 36 | updateFormValue: (nextFormValue: any) => void; 37 | formValue: any; 38 | }; 39 | 40 | export const BindingContext = React.createContext({ 41 | getValue() {}, 42 | updateBindingValue() {}, 43 | } as any); 44 | 45 | BindingContext.displayName = 'ReactFormalValueContext'; 46 | 47 | export const useBindingContext = () => { 48 | return useContext(BindingContext); 49 | }; 50 | 51 | type Setter = ( 52 | path: string, 53 | value: TValue | undefined, 54 | fieldValue: unknown, 55 | ) => TValue; 56 | 57 | type Props = { 58 | formValue?: TValue; 59 | onChange(value: TValue, paths: string[]): void; 60 | getSchemaForPath: (path: string, value: TValue) => any; 61 | getter?: (path: string, value?: TValue | undefined) => any; 62 | setter?: ( 63 | path: string, 64 | value: TValue | undefined, 65 | fieldValue: unknown, 66 | defaultSetter: Setter, 67 | ) => TValue; 68 | }; 69 | 70 | const isEvent = (e: any): e is SyntheticEvent => 71 | typeof e == 'object' && e != null && 'target' in e; 72 | 73 | function parseValueFromEvent( 74 | target: HTMLInputElement & Omit, 75 | fieldValue: any, 76 | fieldSchema?: any, 77 | ) { 78 | const { type, value, checked, options, multiple, files } = target; 79 | 80 | if (type === 'file') return multiple ? files : files && files[0]; 81 | if (multiple) { 82 | // @ts-ignore 83 | const innerType = fieldSchema?._subType?._type; 84 | 85 | return Array.from(options) 86 | .filter((opt) => opt.selected) 87 | .map(({ value: option }) => 88 | innerType == 'number' ? parseFloat(option) : option, 89 | ); 90 | } 91 | 92 | if (/number|range/.test(type)) { 93 | let parsed = parseFloat(value); 94 | return isNaN(parsed) ? null : parsed; 95 | } 96 | if (type === 'checkbox') { 97 | const isArray = Array.isArray(fieldValue); 98 | 99 | const isBool = !isArray && (fieldSchema as any)?._type === 'boolean'; 100 | 101 | if (isBool) return checked; 102 | 103 | const nextValue = isArray ? [...fieldValue] : []; 104 | const idx = nextValue.indexOf(value); 105 | 106 | if (checked) { 107 | if (idx === -1) nextValue.push(value); 108 | } else nextValue.splice(idx, 1); 109 | 110 | return nextValue; 111 | } 112 | 113 | return value; 114 | } 115 | 116 | function useFormBindingContext({ 117 | formValue, 118 | onChange, 119 | setter = formSetter, 120 | getter = formGetter, 121 | getSchemaForPath, 122 | }: Props) { 123 | // Why is this so complicated? 124 | // Well, calling onChange, from a binding multiple times would trigger 125 | // a change multiple times. Duh. This change, when controlled, might not flush 126 | // back through by the time the next change is called, leaving the updateBindingValue() 127 | // with a stale copy of `model`. React's setState avoids this with it's function 128 | // signature of useState, so we "queue" model changes locally in state, and 129 | // then "flush" them in an effect when the update is finished. 130 | let formValueRef = useUpdatedRef(formValue); 131 | let pendingChangeRef = useRef(false); 132 | let [pendingChange, setPendingChange] = useState<[TValue, string[]]>([ 133 | formValue!, 134 | [], 135 | ]); 136 | 137 | // This assumes that we won't get an update until all the queued setState's fire, 138 | // then if there is a pending change we fire onChange with it and the consolidated 139 | // paths 140 | useLayoutEffect(() => { 141 | const [nextFormValue, paths] = pendingChange; 142 | if (pendingChangeRef.current) { 143 | pendingChangeRef.current = false; 144 | onChange(nextFormValue, paths); 145 | } 146 | }); 147 | 148 | const updateBindingValue = useCallback( 149 | (mapValue: any, args: any) => { 150 | setPendingChange((pendingState) => { 151 | let [nextModel, paths] = pendingState; 152 | 153 | // If there are no unflushed changes then use the current props model, assuming it 154 | // would be up to date. 155 | if (!pendingChangeRef.current) { 156 | pendingChangeRef.current = true; 157 | nextModel = formValueRef.current!; 158 | paths = []; 159 | } 160 | 161 | Object.keys(mapValue).forEach((key) => { 162 | let field = mapValue[key]; 163 | let value: any; 164 | 165 | if (typeof field === 'function') value = field(...args); 166 | else if (field === '.' || field == null || args[0] == null) 167 | value = args[0]; 168 | else { 169 | value = expr.getter(field, true)(args[0]); 170 | } 171 | 172 | if (paths.indexOf(key) === -1) paths.push(key); 173 | 174 | if (isEvent(value)) 175 | value = parseValueFromEvent( 176 | value.target as any, 177 | formGetter(key, nextModel), 178 | getSchemaForPath(key, nextModel), 179 | ); 180 | 181 | nextModel = setter!(key, nextModel, value, formSetter); 182 | }); 183 | 184 | return [nextModel, paths]; 185 | }); 186 | }, 187 | [formValueRef, getSchemaForPath, setter], 188 | ); 189 | 190 | const getValue = useCallback( 191 | (pathOrAccessor: any) => 192 | typeof pathOrAccessor === 'function' 193 | ? pathOrAccessor(formValue, getter) 194 | : getter(pathOrAccessor, formValue), 195 | [getter, formValue], 196 | ); 197 | 198 | return useMemo( 199 | () => ({ 200 | getValue, 201 | updateBindingValue, 202 | getSchemaForPath: getSchemaForPath as (path: string) => any, 203 | updateFormValue: (nextFormValue: TValue) => { 204 | setPendingChange(() => { 205 | pendingChangeRef.current = true; 206 | return [nextFormValue, []]; 207 | }); 208 | }, 209 | formValue, 210 | }), 211 | [ 212 | getValue, 213 | updateBindingValue, 214 | setPendingChange, 215 | getSchemaForPath, 216 | formValue, 217 | ], 218 | ); 219 | } 220 | 221 | export default useFormBindingContext; 222 | -------------------------------------------------------------------------------- /src/Contexts.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | import { Errors } from './types.js'; 3 | import { EMPTY_ERRORS } from './Errors.js'; 4 | import { ValidationPaths } from './errorManager.js'; 5 | 6 | export interface FormActions { 7 | yupContext: any; 8 | onSubmit: () => void; 9 | onReset: () => void; 10 | onValidate: (fields: ValidationPaths, event: string, args: any[]) => void; 11 | onFieldError: (name: string, errors: Errors) => void; 12 | formHasValidation: () => boolean; 13 | } 14 | 15 | export interface FormContextValue { 16 | touched: Record; 17 | errors: Errors; 18 | actions: FormActions; 19 | 20 | submits: { 21 | submitCount: number; 22 | submitAttempts: number; 23 | submitting: boolean; 24 | resets: number; 25 | }; 26 | } 27 | 28 | const FormActionContext = createContext(null as any); 29 | const FormTouchedContext = createContext({}); 30 | const FormErrorContext = createContext(EMPTY_ERRORS); 31 | const FormSubmitContext = createContext({ 32 | submitCount: 0, 33 | submitAttempts: 0, 34 | submitting: false, 35 | resets: 0, 36 | }); 37 | 38 | export function FormProvider({ 39 | children, 40 | value, 41 | }: { 42 | children: React.ReactNode; 43 | value: FormContextValue; 44 | }) { 45 | return ( 46 | 47 | 48 | 49 | 50 | {children} 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export const useFormActions = () => useContext(FormActionContext); 59 | export const useFormTouched = () => useContext(FormTouchedContext); 60 | export const useFormErrors = () => useContext(FormErrorContext); 61 | export const useFormSubmits = () => useContext(FormSubmitContext); 62 | -------------------------------------------------------------------------------- /src/Errors.ts: -------------------------------------------------------------------------------- 1 | import { Errors } from './types.js'; 2 | import { inPath, toArray } from './utils/paths.js'; 3 | 4 | export const EMPTY_ERRORS: Errors = Object.freeze({}); 5 | 6 | export let isChildPath = (basePath: string, path: string) => 7 | path !== basePath && inPath(basePath, path); 8 | 9 | function mapKeys( 10 | errors: Errors, 11 | baseName: string, 12 | fn: (idx: number, tail: string, path: string) => string | null, 13 | ) { 14 | if (errors === EMPTY_ERRORS) return errors; 15 | 16 | const newErrors: Errors = {}; 17 | let workDone = false; 18 | Object.keys(errors).forEach((path) => { 19 | let newKey: string | null = path; 20 | 21 | if (isChildPath(baseName, path)) { 22 | const matches = path.slice(baseName.length).match(/\[(\d+)\](.*)$/); 23 | newKey = fn(+matches![1], matches![2] || '', path) ?? path; 24 | 25 | if (!workDone && newKey !== path) workDone = true; 26 | } 27 | 28 | newErrors[newKey!] = errors[path]; 29 | }); 30 | 31 | return workDone ? newErrors : errors; 32 | } 33 | 34 | const prefixName = (name: string, baseName: string) => 35 | baseName + (!name || name[0] === '[' ? '' : '.') + name; 36 | 37 | export function prefix(errors: Errors, baseName: string): Errors { 38 | const paths = Object.keys(errors); 39 | const result: Errors = {}; 40 | 41 | paths.forEach((path) => { 42 | result[prefixName(path, baseName)] = errors[path]; 43 | }); 44 | 45 | return result; 46 | } 47 | 48 | export function unprefix(errors: Errors, baseName: string): Errors { 49 | const paths = Object.keys(errors); 50 | const result: Errors = {}; 51 | 52 | paths.forEach((path) => { 53 | const shortened = path.slice(baseName.length).replace(/^\./, ''); 54 | result[shortened] = errors[path]; 55 | }); 56 | return result; 57 | } 58 | 59 | export function pickErrors(errors: Errors, names: string[]) { 60 | if (!names.length) return errors; 61 | 62 | const result: Errors = {}; 63 | for (const name of names) { 64 | if (name in errors) { 65 | result[name] = errors[name]; 66 | } 67 | } 68 | return result; 69 | } 70 | 71 | export function filter(errors: Errors, baseName: string): Errors { 72 | const paths = Object.keys(errors); 73 | const result: Errors = {}; 74 | 75 | paths.forEach((path) => { 76 | if (isChildPath(baseName, path)) { 77 | result[path] = errors[path]; 78 | } 79 | }); 80 | 81 | return result; 82 | } 83 | 84 | export interface FilterAndMapErrorsOptions { 85 | errors?: Errors; 86 | names: string | string[]; 87 | mapErrors?: (errors: Errors, names: string[]) => Errors; 88 | } 89 | 90 | export function filterAndMapErrors({ 91 | errors, 92 | names, 93 | mapErrors = pickErrors, 94 | }: FilterAndMapErrorsOptions): Errors { 95 | if (!errors || errors === EMPTY_ERRORS) return EMPTY_ERRORS; 96 | 97 | return mapErrors(errors, toArray(names)); 98 | } 99 | 100 | export function remove(errors: Errors, ...basePaths: string[]) { 101 | const result: Errors = {}; 102 | for (const path of Object.keys(errors)) { 103 | if (!basePaths.some((b) => inPath(b, path))) { 104 | result[path] = errors[path]; 105 | } 106 | } 107 | 108 | return result; 109 | } 110 | 111 | export function shift(errors: Errors, baseName: string, atIndex = 0) { 112 | const current = `${baseName}[${atIndex}]`; 113 | 114 | return mapKeys(remove(errors, current), baseName, (index, tail) => { 115 | if (index >= atIndex) { 116 | return `${baseName}[${index - 1}]${tail}`; 117 | } 118 | 119 | return null; 120 | }); 121 | } 122 | 123 | export function unshift(errors: Errors, baseName: string, atIndex = 0) { 124 | return mapKeys(errors, baseName, (index, tail) => { 125 | if (index >= atIndex) { 126 | return `${baseName}[${index + 1}]${tail}`; 127 | } 128 | 129 | return null; 130 | }); 131 | } 132 | 133 | export function move( 134 | errors: Errors, 135 | baseName: string, 136 | fromIndex: number, 137 | toIndex: number, 138 | ) { 139 | return mapKeys(errors, baseName, (index, tail) => { 140 | if (fromIndex > toIndex) { 141 | if (index === fromIndex) return `${baseName}[${toIndex}]${tail}`; 142 | // increment everything above the pivot 143 | if (index >= toIndex && index < fromIndex) 144 | return `${baseName}[${index + 1}]${tail}`; 145 | } else if (fromIndex < toIndex) { 146 | if (index === fromIndex) return `${baseName}[${toIndex}]${tail}`; 147 | // decrement everything above the from item we moved 148 | if (index >= fromIndex && index < toIndex) 149 | return `${baseName}[${index - 1}]${tail}`; 150 | } 151 | 152 | return null; 153 | }); 154 | } 155 | 156 | export function swap( 157 | errors: Errors, 158 | baseName: string, 159 | indexA: number, 160 | indexB: number, 161 | ) { 162 | return mapKeys(errors, baseName, (index, tail) => { 163 | if (index === indexA) return `${baseName}[${indexB}]${tail}`; 164 | if (index === indexB) return `${baseName}[${indexA}]${tail}`; 165 | return null; 166 | }); 167 | } 168 | 169 | export function inclusiveMapErrors(errors: Errors, names: string[]) { 170 | if (!names.length || errors === EMPTY_ERRORS) return EMPTY_ERRORS; 171 | let activeErrors: Errors = {}; 172 | let paths = Object.keys(errors); 173 | 174 | names.forEach((name) => { 175 | paths.forEach((path) => { 176 | if (errors[path] && inPath(name, path)) { 177 | activeErrors[path] = errors[path]; 178 | } 179 | }); 180 | }); 181 | 182 | return activeErrors; 183 | } 184 | -------------------------------------------------------------------------------- /src/FieldArray.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { UseFieldProps, MapToValue } from './useField.js'; 4 | import useFieldArray, { 5 | FieldArrayHelpers, 6 | FieldArrayMeta, 7 | UseFieldArrayOptions, 8 | } from './useFieldArray.js'; 9 | 10 | export type RenderFieldArrayProps = UseFieldProps & { 11 | arrayHelpers: FieldArrayHelpers; 12 | meta: FieldArrayMeta; 13 | ref: React.Ref; 14 | }; 15 | 16 | export type FieldArrayProps = UseFieldArrayOptions & { 17 | name: string; 18 | type?: string; 19 | 20 | /** 21 | * Indicates whether child paths of the current FieldArray 22 | * affect the active state of the FieldArray. Does not 23 | * affect which paths are validated, only whether `meta.valid` 24 | * considers child paths for its state. 25 | */ 26 | exclusive?: boolean; 27 | 28 | /** 29 | * Disables validation for the FieldArray. 30 | */ 31 | noValidate?: boolean; 32 | 33 | /** 34 | * Map the Form value to the Field value. By default 35 | * the `name` of the Field is used to extract the relevant 36 | * property from the Form value. 37 | * 38 | * ```jsx static 39 | * pick(formData, 'location', 'locationId')} 43 | * /> 44 | * ``` 45 | */ 46 | mapToValue?: MapToValue; 47 | 48 | children: ( 49 | value: T[], 50 | helpers: FieldArrayHelpers, 51 | meta: FieldArrayMeta, 52 | ) => React.ReactNode; 53 | }; 54 | 55 | /** 56 | * ``, unlike ``, does not render any component, and 57 | * is essentially a render prop version of [`useFieldArray`](/api/useFieldArray), accepting all 58 | * the same options. 59 | * 60 | * @memberof Form 61 | */ 62 | function FieldArray({ children, ...props }: FieldArrayProps) { 63 | const [values, arrayHelpers, meta] = useFieldArray(props); 64 | 65 | return <>{children(values, arrayHelpers, meta)}; 66 | } 67 | 68 | FieldArray.displayName = 'FieldArray'; 69 | 70 | // @ts-ignore 71 | FieldArray.propTypes = { 72 | name: PropTypes.string.isRequired, 73 | 74 | /** 75 | * The similar signature as providing a function to `` but with an 76 | * additional `arrayHelpers` object passed to the render function: 77 | * 78 | * ```tsx static 79 | * 80 | * {(values, arrayHelpers, meta) => ... } 81 | * 82 | * ``` 83 | * 84 | * @type {(value: T, arrayHelpers: FieldArrayHelpers, meta; FieldMeta) => ReactNode} 85 | */ 86 | children: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), 87 | }; 88 | 89 | export default FieldArray; 90 | -------------------------------------------------------------------------------- /src/Message.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useMemo } from 'react'; 3 | import { useFormErrors } from './Contexts.js'; 4 | import { Errors } from './types.js'; 5 | import { filterAndMapErrors } from './Errors.js'; 6 | import uniq from './utils/uniqMessage.js'; 7 | 8 | export interface MessageProps { 9 | errors?: Errors; 10 | for: string | string[]; 11 | className?: string; 12 | filter?: (item: any, i: number, list: any[]) => boolean; 13 | 14 | /** 15 | * Map the passed in message object for the field to a string to display 16 | */ 17 | extract?: (errors: any, props: any) => any; 18 | 19 | /** 20 | * A function that maps an array of message strings 21 | * and returns a renderable string or ReactElement. 22 | * 23 | * ```jsx static 24 | * 25 | * {errors => errors.join(', ')} 26 | * 27 | * ``` 28 | */ 29 | children?: (errors: any[], props: any) => React.ReactNode; 30 | } 31 | 32 | /** 33 | * Represents a Form validation error message. Only renders when the 34 | * value that it is `for` is invalid. 35 | * 36 | * @alias FormMessage 37 | * @memberof Form 38 | */ 39 | function Message({ 40 | errors: propsErrors, 41 | for: names, 42 | className, 43 | filter = uniq, 44 | extract = (error: any) => error.message || error, 45 | children = (errors: any[], msgProps: any) => ( 46 | {errors.join(', ')} 47 | ), 48 | ...props 49 | }: MessageProps) { 50 | const formErrors = useFormErrors(); 51 | const inputErrors = propsErrors || formErrors; 52 | const errors = useMemo( 53 | () => 54 | filterAndMapErrors({ 55 | errors: inputErrors, 56 | names, 57 | }), 58 | [names, inputErrors], 59 | ); 60 | 61 | if (!errors || !Object.keys(errors).length) return null; 62 | 63 | return ( 64 | <> 65 | {children(Object.values(errors).flat().filter(filter).map(extract), { 66 | ...props, 67 | className, 68 | })} 69 | 70 | ); 71 | } 72 | 73 | Message.propTypes = { 74 | for: PropTypes.oneOfType([ 75 | PropTypes.string, 76 | PropTypes.arrayOf(PropTypes.string), 77 | ]), 78 | 79 | /** 80 | * A function that maps an array of message strings 81 | * and returns a renderable string or ReactElement. 82 | * 83 | * ```jsx static 84 | * 85 | * {errors => errors.join(', ')} 86 | * 87 | * ``` 88 | */ 89 | children: PropTypes.func, 90 | 91 | /** 92 | * Map the passed in message object for the field to a string to display 93 | */ 94 | extract: PropTypes.func, 95 | 96 | filter: PropTypes.func, 97 | }; 98 | 99 | export default Message; 100 | -------------------------------------------------------------------------------- /src/NestedForm.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { AnyObjectSchema } from 'yup'; 3 | import Form, { FormProps } from './Form.js'; 4 | import useField from './useField.js'; 5 | import { prefix, unprefix } from './Errors.js'; 6 | 7 | const propTypes = { 8 | name: PropTypes.string.isRequired, 9 | schema: PropTypes.object, 10 | errors: PropTypes.object, 11 | context: PropTypes.object, 12 | meta: PropTypes.shape({ 13 | errors: PropTypes.object.isRequired, 14 | onError: PropTypes.func.isRequired, 15 | }), 16 | }; 17 | 18 | export interface NestedFormProps 19 | extends Omit< 20 | FormProps, 21 | 'onError' | 'onChange' | 'value' | 'defaultValue' | 'defaultErrors' 22 | > { 23 | name: string; 24 | } 25 | 26 | /** 27 | * A `Form` component that takes a `name` prop. Functions exactly like a normal 28 | * Form, except that when a `name` is present it will defer errors up to the parent `
`, 29 | * functioning like a ``. 30 | * 31 | * This is useful for encapsulating complex input groups into self-contained 32 | * forms without having to worry about `"very.long[1].paths[4].to.fields"` for names. 33 | */ 34 | function NestedForm({ 35 | name, 36 | schema, 37 | errors, 38 | ...props 39 | }: NestedFormProps) { 40 | const [_, meta] = useField({ 41 | name, 42 | noValidate: true, 43 | validateOn: null, 44 | }); 45 | 46 | return ( 47 | meta.onError(prefix(nextErrors, name))} 53 | errors={unprefix(name ? meta.errors : errors!, name)} 54 | schema={schema || (meta.schema as T)} 55 | context={name ? { ...meta.context, ...props.context } : props.context} 56 | /> 57 | ); 58 | } 59 | 60 | NestedForm.propTypes = propTypes; 61 | 62 | export default NestedForm; 63 | -------------------------------------------------------------------------------- /src/Reset.tsx: -------------------------------------------------------------------------------- 1 | import React, { ElementType, useCallback } from 'react'; 2 | import useFormReset from './useFormReset.js'; 3 | import notify from './utils/notify.js'; 4 | 5 | export interface FormResetProps { 6 | as?: TAs; 7 | onClick?: (...args: any[]) => any; 8 | } 9 | /** 10 | * A Form reset button 11 | * 12 | * @memberof Form 13 | */ 14 | function Reset( 15 | props: FormResetProps & 16 | Omit, 'as' | 'triggers'>, 17 | ) { 18 | const { onClick, as: Component = 'button', ...rest } = props; 19 | const [reset] = useFormReset(); 20 | 21 | const handleClick = useCallback(() => { 22 | notify(onClick); 23 | reset(); 24 | }, [onClick, reset]); 25 | 26 | return ; 27 | } 28 | 29 | export default Reset; 30 | -------------------------------------------------------------------------------- /src/Submit.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useCallback, ElementType } from 'react'; 3 | 4 | import notify from './utils/notify.js'; 5 | import useFormSubmit from './useFormSubmit.js'; 6 | 7 | export interface FormSubmitProps { 8 | as?: TAs; 9 | onClick?: (...args: any[]) => any; 10 | triggers?: string[]; 11 | } 12 | 13 | /** 14 | * A Form submit button, for triggering validations for the entire form or specific fields. 15 | * 16 | * @memberof Form 17 | */ 18 | function Submit( 19 | props: FormSubmitProps & 20 | Omit, 'triggers' | 'as'>, 21 | ) { 22 | const { onClick, triggers, as: Component = 'button', ...rest } = props; 23 | const [submit] = useFormSubmit({ triggers }); 24 | 25 | const handleClick = useCallback( 26 | (...args: any[]) => { 27 | notify(onClick, args); 28 | submit(args); 29 | }, 30 | [onClick, submit], 31 | ); 32 | 33 | return ( 34 | 39 | ); 40 | } 41 | 42 | Submit.propTypes = { 43 | /** 44 | * Specify particular fields to validate in the related form. If empty the entire form will be validated. 45 | */ 46 | triggers: PropTypes.arrayOf(PropTypes.string.isRequired), 47 | 48 | /** 49 | * Control the rendering of the Form Submit component when not using 50 | * the render prop form of `children`. 51 | * 52 | * ```jsx static 53 | * 54 | * Submit 55 | * 56 | * ``` 57 | */ 58 | as: PropTypes.elementType, 59 | 60 | /** 61 | * A string or array of event names that trigger validation. 62 | * 63 | * @default 'onClick' 64 | */ 65 | onClick: PropTypes.func, 66 | }; 67 | 68 | export default Submit; 69 | -------------------------------------------------------------------------------- /src/Summary.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import React from 'react'; 4 | import Message, { MessageProps } from './Message.js'; 5 | 6 | /** 7 | * Display all Form validation `errors` in a single summary list. 8 | * 9 | * ```jsx static 10 | * 14 | * 15 | * 16 | * 17 | * 18 | * 19 | * 20 | * Validate 21 | * 22 | * ``` 23 | * 24 | * @memberof Form 25 | */ 26 | class Summary extends React.PureComponent< 27 | MessageProps & { 28 | formatMessage: (err: any, idx: number, errors: any[]) => React.ReactNode; 29 | }, 30 | any 31 | > { 32 | static propTypes = { 33 | /** 34 | * An error message renderer, Should return a `ReactElement` 35 | * ```ts static 36 | * function( 37 | * message: string, 38 | * idx: number, 39 | * errors: array 40 | * ): ReactElement 41 | * ``` 42 | */ 43 | formatMessage: PropTypes.func.isRequired, 44 | 45 | /** 46 | * A DOM node tag name or Component class the Message should render as. 47 | */ 48 | as: PropTypes.elementType.isRequired, 49 | 50 | /** 51 | * A css class that should be always be applied to the Summary container. 52 | */ 53 | errorClass: PropTypes.string, 54 | 55 | /** 56 | * Specify a group to show errors for, if empty all form errors will be shown in the Summary. 57 | */ 58 | group: PropTypes.string, 59 | }; 60 | 61 | static defaultProps = { 62 | as: 'ul', 63 | formatMessage: (message: any, idx: any) =>
  • {message}
  • , 64 | }; 65 | 66 | render() { 67 | let { formatMessage, ...props } = this.props; 68 | 69 | return ( 70 | {(errors) => errors.map(formatMessage)} 71 | ); 72 | } 73 | } 74 | 75 | export default Summary; 76 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { ValidateOnConfig } from './useField.js'; 2 | 3 | interface Config { 4 | validateOn: ValidateOnConfig; 5 | errorClass: string; 6 | } 7 | 8 | const config: Config = { 9 | validateOn: { change: true, blur: true }, 10 | errorClass: 'invalid-field', 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /src/errorManager.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_ERRORS } from './Errors.js'; 2 | import errToJSON from './utils/errToJSON.js'; 3 | import { trim, inPath } from './utils/paths.js'; 4 | import { ValidationError } from 'yup'; 5 | import { Errors } from './types.js'; 6 | import { uniqBy } from './utils/uniqBy.js'; 7 | 8 | export interface ValidationPathSpec { 9 | path: string; 10 | shallow: boolean; 11 | } 12 | 13 | export type ValidationPaths = Array; 14 | 15 | function trimChildren(rootPath: string, errors: Record) { 16 | let result: Record = {}; 17 | Object.keys(errors).forEach((path) => { 18 | if (rootPath !== path && inPath(rootPath, path)) return; 19 | result[path] = errors[path]; 20 | }); 21 | 22 | return result; 23 | } 24 | 25 | function reduce(paths: ValidationPathSpec[]) { 26 | paths = uniqBy(paths, (p) => p.path); 27 | 28 | if (paths.length <= 1) return paths; 29 | 30 | return paths.reduce((innerPaths, current) => { 31 | innerPaths = innerPaths.filter((p) => !inPath(current.path, p.path)); 32 | 33 | if (!innerPaths.some((p) => inPath(p.path, current.path))) 34 | innerPaths.push(current); 35 | 36 | return innerPaths; 37 | }, []); 38 | } 39 | 40 | export let isValidationError = (err: any): err is ValidationError => 41 | err && err.name === 'ValidationError'; 42 | 43 | export default function errorManager( 44 | handleValidation: ( 45 | path: ValidationPathSpec, 46 | opts?: TOptions, 47 | ) => Error | void | Promise, 48 | ) { 49 | return { 50 | async collect( 51 | paths: ValidationPaths, 52 | pristineErrors = EMPTY_ERRORS, 53 | options?: TOptions, 54 | ): Promise { 55 | const specs = reduce( 56 | paths.map((p) => 57 | typeof p === 'string' ? { path: p, shallow: false } : p, 58 | ), 59 | ); 60 | 61 | let errors = { ...pristineErrors }; 62 | let nextErrors = errors; 63 | let workDone = false; 64 | 65 | specs.forEach(({ path, shallow }) => { 66 | nextErrors = trim(path, nextErrors, shallow); 67 | if (errors !== nextErrors) workDone = true; 68 | }); 69 | 70 | let validations = specs.map((spec) => 71 | Promise.resolve(handleValidation(spec, options)).then( 72 | (validationError) => { 73 | if (!validationError) return true; 74 | 75 | if (!isValidationError(validationError)) throw validationError; 76 | 77 | if (!spec.shallow) { 78 | errToJSON(validationError, nextErrors); 79 | return; 80 | } 81 | 82 | const fieldErrors = errToJSON(validationError); 83 | Object.assign(nextErrors, trimChildren(spec.path, fieldErrors)); 84 | }, 85 | ), 86 | ); 87 | 88 | const results = await Promise.all(validations); 89 | if (!workDone && results.every(Boolean)) return pristineErrors; 90 | return nextErrors; 91 | }, 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module '@restart/hooks/useMergeState' { 4 | declare type Updater = (state: TState) => Partial | null; 5 | /** 6 | * Updates state, partial updates are merged into existing state values 7 | */ 8 | declare type MergeStateSetter = ( 9 | update: Updater | Partial | null, 10 | ) => void; 11 | 12 | declare function useMergeState( 13 | initialState: (() => TState) | TState, 14 | ): [TState, MergeStateSetter] { 15 | }; 16 | 17 | export default useMergeState; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AnyObjectSchema, InferType, ValidationError } from 'yup'; 2 | import Field, { useMergedEventHandlers } from './Field.js'; 3 | import FieldArray from './FieldArray.js'; 4 | import FormComponent, { getter, setter } from './Form.js'; 5 | import Submit from './Submit.js'; 6 | import Message from './Message.js'; 7 | import NestedForm from './NestedForm.js'; 8 | import Summary from './Summary.js'; 9 | import config from './config.js'; 10 | import useField, { ValidateStrategies, splitFieldProps } from './useField.js'; 11 | import useFormValues from './useFormValues.js'; 12 | import useForm from './useForm.js'; 13 | import useFieldArray from './useFieldArray.js'; 14 | import errToJSON from './utils/errToJSON.js'; 15 | import useFormSubmit from './useFormSubmit.js'; 16 | import useErrors from './useErrors.js'; 17 | import useTouched from './useTouched.js'; 18 | import Reset from './Reset.js'; 19 | import useFormReset from './useFormReset.js'; 20 | 21 | export type FieldArrayHelpers = import('./useFieldArray.js').FieldArrayHelpers; 22 | export type FieldArrayMeta = import('./useFieldArray.js').FieldArrayMeta; 23 | export type UseFieldArrayOptions = 24 | import('./useFieldArray.js').UseFieldArrayOptions; 25 | 26 | export type FieldMeta = import('./useField.js').FieldMeta; 27 | export type UseFieldProps = import('./useField.js').UseFieldProps; 28 | export type UseFieldOptions = import('./useField.js').UseFieldOptions; 29 | 30 | export type JsonError = import('./utils/errToJSON.js').JsonError; 31 | export type FieldProps = import('./Field.js').FieldProps; 32 | export type FieldRenderProps = import('./Field.js').FieldRenderProps; 33 | export type FieldInjectedProps = import('./Field.js').FieldInjectedProps; 34 | export type MessageProps = import('./Message.js').MessageProps; 35 | export type FormProps< 36 | TSchema extends AnyObjectSchema, 37 | TValue = InferType, 38 | > = import('./Form.js').FormProps; 39 | 40 | export interface FormStatics { 41 | Field: typeof Field; 42 | FieldArray: typeof FieldArray; 43 | Message: typeof Message; 44 | Submit: typeof Submit; 45 | Reset: typeof Reset; 46 | Summary: typeof Summary; 47 | } 48 | 49 | const setDefaults = (defaults = {}) => { 50 | Object.assign(config, defaults); 51 | }; 52 | 53 | const toFormErrors = (err: ValidationError) => { 54 | if (!err || err.name !== 'ValidationError') 55 | throw new Error('`toErrors()` only works with ValidationErrors.'); 56 | 57 | return errToJSON(err); 58 | }; 59 | 60 | const Form = Object.assign(FormComponent, { 61 | Field, 62 | FieldArray, 63 | Message, 64 | Submit, 65 | Reset, 66 | Summary, 67 | }); 68 | 69 | export { 70 | Form, 71 | Field, 72 | FieldArray, 73 | Message, 74 | Submit, 75 | Reset, 76 | Summary, 77 | NestedForm, 78 | useField, 79 | useMergedEventHandlers, 80 | useFieldArray, 81 | useFormSubmit, 82 | useFormReset, 83 | useFormValues, 84 | useForm, 85 | useErrors, 86 | useTouched, 87 | splitFieldProps, 88 | ValidateStrategies, 89 | setDefaults, 90 | toFormErrors, 91 | getter, 92 | setter, 93 | }; 94 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Errors = Record; 2 | 3 | export type Touched = Record; 4 | 5 | export type ValidateData = { 6 | fields: string[]; 7 | type: string; 8 | args?: any[]; 9 | }; 10 | 11 | export type BeforeSubmitData = { 12 | // Loose type b/c this is the uncast form value 13 | value: any | undefined; 14 | errors: Errors; 15 | }; 16 | -------------------------------------------------------------------------------- /src/useBinding.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useCallback } from 'react'; 2 | import { Mapper, useBindingContext } from './BindingContext.js'; 3 | 4 | export type MapToValue = 5 | | Mapper 6 | | keyof TValue 7 | | { [P in keyof TValue]?: string | Mapper }; 8 | 9 | function extractTargetValue(eventOrValue: SyntheticEvent | TIn) { 10 | if ( 11 | !eventOrValue || 12 | typeof eventOrValue !== 'object' || 13 | !('target' in eventOrValue) 14 | ) 15 | return eventOrValue; 16 | 17 | const { type, value, checked, multiple, files } = 18 | eventOrValue.target as HTMLInputElement; 19 | 20 | if (type === 'file') return multiple ? files : files && files[0]; 21 | if (/number|range/.test(type)) { 22 | let parsed = parseFloat(value); 23 | return isNaN(parsed) ? null : parsed; 24 | } 25 | 26 | return /checkbox|radio/.test(type) ? checked : value; 27 | } 28 | 29 | function useBinding( 30 | bindTo: Mapper | keyof TValue, 31 | mapValue: MapToValue = extractTargetValue as any, 32 | ) { 33 | const { updateBindingValue, getValue } = useBindingContext(); 34 | const value = getValue(bindTo); 35 | 36 | const handleEvent = useCallback( 37 | (...args: any[]) => { 38 | let mapper = mapValue; 39 | if (typeof bindTo === 'string' && typeof mapValue !== 'object') { 40 | mapper = { [bindTo]: mapValue } as any; 41 | } 42 | 43 | if (mapper) { 44 | updateBindingValue(mapper, args); 45 | } 46 | }, 47 | [bindTo, mapValue, updateBindingValue], 48 | ); 49 | 50 | return [value, handleEvent] as const; 51 | } 52 | 53 | export default useBinding; 54 | -------------------------------------------------------------------------------- /src/useErrors.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useFormErrors } from './Contexts.js'; 3 | import memoize from './utils/memoize-one.js'; 4 | // @ts-expect-error 5 | import shallowequal from 'shallowequal'; 6 | import { filterAndMapErrors, inclusiveMapErrors } from './Errors.js'; 7 | import { Errors } from './types.js'; 8 | 9 | type UseErrorOptions = { inclusive?: boolean }; 10 | 11 | function isFilterErrorsEqual([a]: any[], [b]: any[]) { 12 | let isEqual = 13 | (a.errors === b.errors || shallowequal(a.errors, b.errors)) && 14 | a.names === b.names && 15 | a.mapErrors === b.mapErrors; 16 | 17 | return isEqual; 18 | } 19 | 20 | /** 21 | * Returns the field errors for the form, or a subset of field errors if paths is provided. 22 | * 23 | * @param paths a path or set of paths to retrieve errors for. 24 | * @param options 25 | * @param {boolean=} options.inclusive By default, only errors with exact matches on each path are returned. 26 | * Set to `false` to also return errors for a path and any nested paths 27 | * 28 | * @returns {Errors} 29 | */ 30 | function useErrors( 31 | paths?: string | string[], 32 | { inclusive }: UseErrorOptions = {}, 33 | ): Errors { 34 | const errors = useFormErrors(); 35 | const memoFilterAndMapErrors = useMemo( 36 | () => memoize(filterAndMapErrors, isFilterErrorsEqual), 37 | [], 38 | ); 39 | 40 | return paths 41 | ? memoFilterAndMapErrors({ 42 | errors, 43 | names: paths, 44 | mapErrors: !inclusive ? undefined : inclusiveMapErrors, 45 | }) 46 | : errors; 47 | } 48 | 49 | export default useErrors; 50 | -------------------------------------------------------------------------------- /src/useFieldArray.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | 3 | import { useRef, useMemo } from 'react'; 4 | import { Errors } from './types.js'; 5 | import { FieldMeta, UseFieldMetaOptions, useFieldMeta } from './useField.js'; 6 | import { move, remove, shift, unshift } from './Errors.js'; 7 | import { ValidationPathSpec } from './errorManager.js'; 8 | import { useFormActions } from './Contexts.js'; 9 | 10 | export type FieldArrayMeta = FieldMeta; 11 | 12 | export interface FieldArrayHelpers { 13 | /** Add an item to the beginning of the array */ 14 | unshift(item: T): void; 15 | 16 | /** 17 | * Add an item to the end of the array 18 | * @deprecated use `push` 19 | */ 20 | add(item: T): void; 21 | 22 | /** Add an item to the end of the array */ 23 | push(item: T): void; 24 | 25 | /** Insert an item at the provided index */ 26 | insert(item: T, index: number): void; 27 | 28 | /** Move an item to a new index */ 29 | move(item: T, toIndex: number): void; 30 | 31 | /** Remove an item from the list */ 32 | remove(item: T): void; 33 | 34 | /** 35 | * update or replace an item with a new one. 36 | */ 37 | update(item: T, oldItem: T): void; 38 | 39 | onItemError(name: string, errors: Errors): void; 40 | } 41 | 42 | export type UseFieldArrayOptions = Omit & { 43 | validates?: string | string[] | null; 44 | }; 45 | 46 | /** 47 | * Retrieve the values at a given path as well as a set of array helpers 48 | * for manipulating list values. 49 | * 50 | * ```jsx 51 | * function ContactList() { 52 | * const [values, arrayHelpers, meta] = useFieldArray("contacts") 53 | * 54 | * return ( 55 | *
      56 | * {values.map((value, idx) => ( 57 | *
    • 58 | * 59 | * 60 | * 63 | *
    • 64 | * )} 65 | *
    66 | * ) 67 | * } 68 | * ``` 69 | * 70 | * @param name A field path, should point to an array value in the form data 71 | */ 72 | 73 | function useFieldArray( 74 | name: string, 75 | ): [T[], FieldArrayHelpers, FieldMeta]; 76 | /** 77 | * Retrieve the values at a given path as well as a set of array helpers 78 | * for manipulating list values. 79 | * 80 | * ```jsx 81 | * function ContactList() { 82 | * const [values, arrayHelpers, meta] = useFieldArray({ 83 | * name: 'contacts', 84 | * validates: 'otherField' 85 | * }) 86 | * 87 | * return ( 88 | *
      89 | * {values.map((value, idx) => ( 90 | *
    • 91 | * 92 | * 93 | * 96 | *
    • 97 | * )} 98 | *
    99 | * ) 100 | * } 101 | * ``` 102 | * 103 | * @param name A field path, should point to an array value in the form data 104 | */ 105 | function useFieldArray( 106 | options: UseFieldArrayOptions, 107 | ): [T[], FieldArrayHelpers, FieldMeta]; 108 | function useFieldArray( 109 | optionsOrName: string | UseFieldArrayOptions, 110 | ): [T[], FieldArrayHelpers, FieldMeta] { 111 | let options = 112 | typeof optionsOrName === 'string' 113 | ? { name: optionsOrName, exclusive: true } 114 | : optionsOrName; 115 | 116 | let { name } = options; 117 | 118 | const actions = useFormActions(); 119 | 120 | // TODO: doesn't shallow validate validates 121 | const fieldsToValidate = useMemo( 122 | () => [{ path: name, shallow: true }], 123 | [name], 124 | ); 125 | 126 | const meta = useFieldMeta({ 127 | ...options, 128 | exclusive: options.exclusive ?? true, 129 | validates: fieldsToValidate, 130 | }); 131 | 132 | const { errors, onError, value, onChange, update } = meta; 133 | 134 | const sendErrors = (fn: (e: Errors, n: string) => Errors) => { 135 | onError(fn(errors || {}, options.name)); 136 | }; 137 | 138 | const helpers: FieldArrayHelpers = { 139 | unshift: (item: T) => helpers.insert(item, 0), 140 | 141 | add: (item: T) => helpers.push(item), 142 | 143 | push: (item: T) => helpers.insert(item, value ? value.length : 0), 144 | 145 | insert: (item: T, index: number) => { 146 | const newValue = value == null ? [] : [...value]; 147 | 148 | newValue.splice(index, 0, item); 149 | 150 | onChange(newValue); 151 | sendErrors((errors, name) => unshift(errors, name, index)); 152 | }, 153 | 154 | move: (item: T, toIndex: number) => { 155 | const fromIndex = value.indexOf(item); 156 | const newValue = value == null ? [] : [...value]; 157 | 158 | if (fromIndex === -1) 159 | throw new Error('`onMove` must be called with an item in the array'); 160 | 161 | newValue.splice(toIndex, 0, ...newValue.splice(fromIndex, 1)); 162 | 163 | // FIXME: doesn't handle syncing error state. , { action: 'move', toIndex, fromIndex } 164 | onChange(newValue); 165 | 166 | sendErrors((errors, name) => move(errors, name, fromIndex, toIndex)); 167 | }, 168 | 169 | remove: (item: T) => { 170 | if (value == null) return; 171 | 172 | const index = value.indexOf(item); 173 | onChange(value.filter((v: any) => v !== item)); 174 | 175 | sendErrors((errors, name) => shift(errors, name, index)); 176 | }, 177 | 178 | onItemError: (name: string, errors: Errors) => { 179 | sendErrors((fieldErrors) => ({ 180 | ...remove(fieldErrors, name), 181 | ...errors, 182 | })); 183 | }, 184 | 185 | update: (updatedItem: T, oldItem: T) => { 186 | const index = value.indexOf(oldItem); 187 | const newValue = value == null ? [] : [...value]; 188 | 189 | newValue.splice(index, 1, updatedItem); 190 | 191 | update(newValue); 192 | // @ts-ignore 193 | if (options.noValidate) return; 194 | 195 | actions?.onValidate([`${name}[${index}]`], 'onChange', []); 196 | }, 197 | }; 198 | 199 | return [ 200 | value as T[], 201 | Object.assign(useRef({} as any).current, helpers), 202 | meta, 203 | ]; 204 | } 205 | 206 | export default useFieldArray; 207 | -------------------------------------------------------------------------------- /src/useFieldSchema.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { type AnySchema } from 'yup'; 3 | import { useBindingContext } from './BindingContext.js'; 4 | 5 | function useFieldSchema(path: string | undefined) { 6 | const { getSchemaForPath } = useBindingContext(); 7 | 8 | return useMemo(() => { 9 | if (!path) return; 10 | 11 | try { 12 | return getSchemaForPath(path); 13 | } catch (err) { 14 | /* ignore */ 15 | } 16 | }, [getSchemaForPath, path]); 17 | } 18 | 19 | export default useFieldSchema; 20 | -------------------------------------------------------------------------------- /src/useForm.ts: -------------------------------------------------------------------------------- 1 | import { useBindingContext } from './BindingContext.js'; 2 | 3 | /** 4 | * Returns the current form value and onChange handler for the Form 5 | */ 6 | function useForm() { 7 | const ctx = useBindingContext(); 8 | return [ctx.formValue, ctx.updateFormValue] as const; 9 | } 10 | 11 | export default useForm; 12 | -------------------------------------------------------------------------------- /src/useFormReset.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { useFormActions, useFormSubmits } from './Contexts.js'; 3 | 4 | export default function useFormReset() { 5 | const actions = useFormActions(); 6 | const { resets } = useFormSubmits(); 7 | const handleReset = useCallback(() => { 8 | if (!actions) { 9 | if (process.env.NODE_ENV !== 'production') 10 | return console.error( 11 | 'A Form submit event ' + 12 | 'was triggered from a component outside the context of a Form. ' + 13 | 'The Button should be wrapped in a Form component', 14 | ); 15 | } 16 | actions.onReset(); 17 | }, [actions]); 18 | 19 | return [handleReset, useMemo(() => ({ resets }), [resets])] as const; 20 | } 21 | -------------------------------------------------------------------------------- /src/useFormSubmit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { useFormActions, useFormSubmits } from './Contexts.js'; 3 | import useErrors from './useErrors.js'; 4 | 5 | export interface UseFormSubmitOptions { 6 | triggers?: string[]; 7 | } 8 | 9 | /** 10 | * 11 | * @param options 12 | * @param {string[]} options.trigger A set of paths to trigger validation for 13 | */ 14 | export default function useFormSubmit({ triggers }: UseFormSubmitOptions = {}) { 15 | const actions = useFormActions(); 16 | const submits = useFormSubmits(); 17 | 18 | const errors = useErrors(triggers); 19 | 20 | const handleSubmit = useCallback( 21 | (...args: any[]) => { 22 | if (!actions) { 23 | if (process.env.NODE_ENV !== 'production') 24 | return console.error( 25 | 'A Form submit event ' + 26 | 'was triggered from a component outside the context of a Form. ' + 27 | 'The Button should be wrapped in a Form component', 28 | ); 29 | } 30 | 31 | if (triggers && triggers.length) { 32 | actions.onValidate(triggers, 'submit', args); 33 | } else actions.onSubmit(); 34 | }, 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | [actions, triggers && triggers.join(',')], 37 | ); 38 | 39 | return [ 40 | handleSubmit, 41 | useMemo(() => ({ errors, ...submits }), [errors, submits]), 42 | ] as const; 43 | } 44 | -------------------------------------------------------------------------------- /src/useFormValues.ts: -------------------------------------------------------------------------------- 1 | import { useBindingContext } from './BindingContext.js'; 2 | 3 | /** 4 | * Returns the current Field value at the provided path. 5 | * 6 | * @param {string} field a field path to observe. 7 | * @returns {any} 8 | */ 9 | function useFormValues(field: string): any; 10 | 11 | /** 12 | * Returns an array of values for the provided field paths. 13 | * 14 | * @param {string[]} fields a set of field paths to observe. 15 | * @returns {Array} 16 | */ 17 | function useFormValues(fields: string[]): any[]; 18 | 19 | function useFormValues(fields: string | string[]): undefined | any | any[] { 20 | const ctx = useBindingContext(); 21 | if (!ctx) return; 22 | 23 | return Array.isArray(fields) 24 | ? fields.map((f) => ctx.getValue(f)) 25 | : ctx.getValue(fields); 26 | } 27 | 28 | export default useFormValues; 29 | -------------------------------------------------------------------------------- /src/useTouched.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import memoize from './utils/memoize-one.js'; 3 | import { useFormTouched } from './Contexts.js'; 4 | import { filterAndMapErrors } from './Errors.js'; 5 | import { Errors, Touched } from './types.js'; 6 | 7 | /** 8 | * Returns all errors for the form. 9 | * 10 | * @returns {Touched} 11 | */ 12 | function useTouched(): Errors; 13 | /** 14 | * Returns the current Field value at the provided path. 15 | * 16 | * @param path a path to retrieve errors for. 17 | * @returns {Touched} 18 | */ 19 | function useTouched(path: string): Touched; 20 | /** 21 | * Returns an array of values for the provided field paths. 22 | * 23 | * @param paths a set of paths to retrieve errors for. 24 | * @returns {Touched} 25 | */ 26 | function useTouched(paths: string[] | undefined): Touched; 27 | function useTouched(paths?: string | string[]): Touched { 28 | const touched = useFormTouched(); 29 | 30 | const memoFilterAndMapErrors = useMemo( 31 | () => 32 | memoize( 33 | filterAndMapErrors, 34 | ([a], [b]) => 35 | a.errors === b.errors && 36 | a.names === b.names && 37 | a.mapErrors === b.mapErrors, 38 | ), 39 | [], 40 | ); 41 | 42 | return paths 43 | ? memoFilterAndMapErrors({ errors: touched, names: paths }) 44 | : touched; 45 | } 46 | 47 | export default useTouched; 48 | -------------------------------------------------------------------------------- /src/utils/errToJSON.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'yup'; 2 | 3 | export type JsonError = { values: any; type: string; message: any }; 4 | 5 | export default function errToJSON( 6 | error: ValidationError, 7 | target: Record = {}, 8 | ): Record { 9 | if (error.inner.length) { 10 | error.inner.forEach((inner) => { 11 | errToJSON(inner, target); 12 | }); 13 | 14 | return target; 15 | } 16 | 17 | let path = error.path || ''; 18 | let existing = target[path]; 19 | 20 | let json: JsonError = { 21 | message: error.message, 22 | values: error.params, 23 | type: error.type!, 24 | }; 25 | 26 | target[path] = existing ? [...existing, json] : [json]; 27 | 28 | return target; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/isNativeType.ts: -------------------------------------------------------------------------------- 1 | const types = /^(button|checkbox|color|date|datetime|datetime-local|email|file|month|number|password|radio|range|reset|search|submit|tel|text|time|url|w)$/ 2 | 3 | export default function isNativeType(type: unknown): type is string { 4 | return !!(typeof type === 'string' && type.match(types)) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/memoize-one.ts: -------------------------------------------------------------------------------- 1 | // VENDORED memoize-one from npm 2 | 3 | const safeIsNaN = 4 | Number.isNaN || 5 | function ponyfill(value: unknown): boolean { 6 | // // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN#polyfill 7 | // NaN is the only value in JavaScript which is not equal to itself. 8 | return typeof value === 'number' && value !== value; 9 | }; 10 | 11 | function isEqual(first: unknown, second: unknown): boolean { 12 | if (first === second) { 13 | return true; 14 | } 15 | 16 | // Special case for NaN (NaN !== NaN) 17 | if (safeIsNaN(first) && safeIsNaN(second)) { 18 | return true; 19 | } 20 | 21 | return false; 22 | } 23 | 24 | function areInputsEqual( 25 | newInputs: readonly unknown[], 26 | lastInputs: readonly unknown[], 27 | ): boolean { 28 | // no checks needed if the inputs length has changed 29 | if (newInputs.length !== lastInputs.length) { 30 | return false; 31 | } 32 | // Using for loop for speed. It generally performs better than array.every 33 | // https://github.com/alexreardon/memoize-one/pull/59 34 | for (let i = 0; i < newInputs.length; i++) { 35 | if (!isEqual(newInputs[i], lastInputs[i])) { 36 | return false; 37 | } 38 | } 39 | return true; 40 | } 41 | 42 | export type EqualityFn any> = ( 43 | newArgs: Parameters, 44 | lastArgs: Parameters, 45 | ) => boolean; 46 | 47 | export type MemoizedFn any> = { 48 | clear: () => void; 49 | ( 50 | this: ThisParameterType, 51 | ...args: Parameters 52 | ): ReturnType; 53 | }; 54 | 55 | // internal type 56 | type Cache any> = { 57 | lastThis: ThisParameterType; 58 | lastArgs: Parameters; 59 | lastResult: ReturnType; 60 | }; 61 | 62 | function memoizeOne any>( 63 | resultFn: TFunc, 64 | isEqual: EqualityFn = areInputsEqual, 65 | ): MemoizedFn { 66 | let cache: Cache | null = null; 67 | 68 | // breaking cache when context (this) or arguments change 69 | function memoized( 70 | this: ThisParameterType, 71 | ...newArgs: Parameters 72 | ): ReturnType { 73 | if (cache && cache.lastThis === this && isEqual(newArgs, cache.lastArgs)) { 74 | return cache.lastResult; 75 | } 76 | 77 | // Throwing during an assignment aborts the assignment: https://codepen.io/alexreardon/pen/RYKoaz 78 | // Doing the lastResult assignment first so that if it throws 79 | // the cache will not be overwritten 80 | const lastResult = resultFn.apply(this, newArgs); 81 | cache = { 82 | lastResult, 83 | lastArgs: newArgs, 84 | lastThis: this, 85 | }; 86 | 87 | return lastResult; 88 | } 89 | 90 | // Adding the ability to clear the cache of a memoized function 91 | memoized.clear = function clear() { 92 | cache = null; 93 | }; 94 | 95 | return memoized; 96 | } 97 | 98 | export default memoizeOne; 99 | -------------------------------------------------------------------------------- /src/utils/notify.ts: -------------------------------------------------------------------------------- 1 | export default function notify any>( 2 | handler: T | undefined, 3 | args?: Parameters, 4 | ) { 5 | // FIXME: seems to be a babel bug here... 6 | // eslint-disable-next-line prefer-spread 7 | if (handler) handler.apply(null, args as any); 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/paths.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | 3 | import prop from 'property-expr'; 4 | 5 | export const toArray = (arr?: T | T[] | null): T[] => { 6 | const next: T[] = []; 7 | return arr == null ? next : next.concat(arr); 8 | }; 9 | 10 | export function isQuoted(str: string) { 11 | return typeof str === 'string' && str && (str[0] === '"' || str[0] === "'"); 12 | } 13 | 14 | export function clean(part: string) { 15 | return isQuoted(part) ? part.substr(1, part.length - 2) : part; 16 | } 17 | 18 | export function inPath(basePath: string, childPath: string) { 19 | if (basePath === childPath) return true; 20 | 21 | let partsA = prop.split(basePath) || []; 22 | let partsB = prop.split(childPath) || []; 23 | 24 | if (partsA.length > partsB.length) return false; 25 | 26 | return partsA.every((part, idx) => clean(part) === clean(partsB[idx])); 27 | } 28 | 29 | export function reduce(paths: string[]) { 30 | paths = Array.from(new Set(toArray(paths))); 31 | 32 | if (paths.length <= 1) return paths; 33 | 34 | return paths.reduce((paths, current) => { 35 | paths = paths.filter((p) => !inPath(current, p)); 36 | 37 | if (!paths.some((p) => inPath(p, current))) paths.push(current); 38 | 39 | return paths; 40 | }, []); 41 | } 42 | 43 | export function trim( 44 | rootPath: string, 45 | pathHash: Record, 46 | exact = false, 47 | ) { 48 | let workDone = false; 49 | let result: Record = {}; 50 | 51 | let matches = exact 52 | ? (p: string) => p === rootPath 53 | : (p: string) => inPath(rootPath, p); 54 | 55 | Object.keys(pathHash).forEach((path) => { 56 | if (matches(path)) { 57 | return (workDone = true); 58 | } 59 | 60 | result[path] = pathHash[path]; 61 | }); 62 | 63 | return workDone ? result : pathHash; 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/uniqBy.ts: -------------------------------------------------------------------------------- 1 | export function uniqBy(array: T[], predicate: (item: T) => any): T[] { 2 | const keys = new Set(); 3 | const result: T[] = []; 4 | 5 | for (const item of array) { 6 | const key = predicate(item); 7 | 8 | if (!keys.has(key)) { 9 | keys.add(key); 10 | result.push(item); 11 | } 12 | } 13 | 14 | return result; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/uniqMessage.ts: -------------------------------------------------------------------------------- 1 | let extract = (item: any) => item.message || item; 2 | 3 | export default function uniqMessage(msg: any, i: number, list: any[]) { 4 | let idx = -1; 5 | 6 | msg = extract(msg); 7 | 8 | list.some((item, ii) => { 9 | if (extract(item) === msg) { 10 | idx = ii; 11 | return true; 12 | } 13 | }); 14 | 15 | return idx === i; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/updateIn.ts: -------------------------------------------------------------------------------- 1 | import prop from 'property-expr' 2 | 3 | const IS_ARRAY = /^\d+$/ 4 | 5 | function isQuoted(str: string): boolean { 6 | return !!( 7 | typeof str === 'string' && 8 | str && 9 | (str[0] === '"' || str[0] === "'") 10 | ) 11 | } 12 | 13 | function copy(value: T): T { 14 | return Array.isArray(value) 15 | ? value.concat() 16 | : value !== null && typeof value === 'object' 17 | ? // 18 | // @ts-ignore 19 | Object.assign(new value.constructor(), value) 20 | : value 21 | } 22 | 23 | function clean(part: string) { 24 | return isQuoted(part) ? part.substr(1, part.length - 2) : part 25 | } 26 | 27 | export default function update( 28 | model: T | undefined, 29 | path: string, 30 | value: any, 31 | ): T { 32 | let parts = prop.split(path) 33 | let newModel: any = copy(model) 34 | let part: string 35 | let islast: boolean 36 | 37 | if (newModel == null) newModel = IS_ARRAY.test(parts[0]) ? [] : {} 38 | 39 | let current = newModel 40 | 41 | for (let idx = 0; idx < parts.length; idx++) { 42 | islast = idx === parts.length - 1 43 | part = clean(parts[idx]) 44 | 45 | if (islast) current[part] = value 46 | else { 47 | current = current[part] = 48 | current[part] == null 49 | ? IS_ARRAY.test(parts[idx + 1]) 50 | ? [] 51 | : {} 52 | : copy(current[part]) 53 | } 54 | } 55 | 56 | return newModel as T 57 | } 58 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: jason/jest 2 | rules: 3 | react/prop-types: off 4 | '@typescript-eslint/no-shadow': off 5 | -------------------------------------------------------------------------------- /test/ErrorUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from '../src/Errors'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('PATH utils', () => { 5 | describe('shift', () => { 6 | it('should shift', () => { 7 | expect( 8 | Utils.shift( 9 | { 10 | 'foo[0].bar': 0, 11 | 'foo[1].bar': 1, 12 | 'foo[2].bar': 2, 13 | 'foo[3].bar': 3, 14 | }, 15 | 'foo', 16 | ), 17 | ).toEqual({ 18 | 'foo[0].bar': 1, 19 | 'foo[1].bar': 2, 20 | 'foo[2].bar': 3, 21 | }); 22 | }); 23 | 24 | it('should shift at index', () => { 25 | expect( 26 | Utils.shift( 27 | { 28 | 'foo[0].bar': 0, 29 | 'foo[1].bar': 1, 30 | 'foo[2].bar': 2, 31 | 'foo[3].bar': 3, 32 | }, 33 | 'foo', 34 | 2, 35 | ), 36 | ).toEqual({ 37 | 'foo[0].bar': 0, 38 | 'foo[1].bar': 1, 39 | 'foo[2].bar': 3, 40 | }); 41 | }); 42 | }); 43 | 44 | describe('unshift', () => { 45 | it('should unshift', () => { 46 | expect( 47 | Utils.unshift( 48 | { 49 | 'foo[0].bar': 0, 50 | 'foo[1].bar': 1, 51 | 'foo[2].bar': 2, 52 | 'foo[3].bar': 3, 53 | }, 54 | 'foo', 55 | ), 56 | ).toEqual({ 57 | 'foo[1].bar': 0, 58 | 'foo[2].bar': 1, 59 | 'foo[3].bar': 2, 60 | 'foo[4].bar': 3, 61 | }); 62 | }); 63 | 64 | it('should unshift at index', () => { 65 | expect( 66 | Utils.unshift( 67 | { 68 | 'foo[0].bar': 0, 69 | 'foo[1].bar': 1, 70 | 'foo[2].bar': 2, 71 | 'foo[3].bar': 3, 72 | }, 73 | 'foo', 74 | 2, 75 | ), 76 | ).toEqual({ 77 | 'foo[0].bar': 0, 78 | 'foo[1].bar': 1, 79 | 'foo[3].bar': 2, 80 | 'foo[4].bar': 3, 81 | }); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/FieldArray.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useImperativeHandle } from 'react'; 2 | import { array, object, string } from 'yup'; 3 | import { describe, it, vi, expect } from 'vitest'; 4 | import { act, fireEvent, render } from '@testing-library/react'; 5 | 6 | import { Form, useFieldArray } from '../src'; 7 | 8 | describe('FieldArray', () => { 9 | let schema = object({ 10 | colors: array() 11 | .of( 12 | object({ 13 | name: string().required(), 14 | hexCode: string().required(), 15 | }), 16 | ) 17 | .default(() => [{ name: 'red', hexCode: '#ff0000' }]), 18 | }); 19 | 20 | const ColorList = React.forwardRef(({ name }: any, ref) => { 21 | const [values, arrayHelpers] = useFieldArray(name); 22 | 23 | useImperativeHandle( 24 | ref, 25 | () => ({ 26 | remove: (index) => { 27 | arrayHelpers.remove(values[index]); 28 | }, 29 | }), 30 | [arrayHelpers, values], 31 | ); 32 | 33 | return ( 34 |
      35 | {values.map((value, idx) => ( 36 |
    • 37 | 38 | 39 |
    • 40 | ))} 41 |
    42 | ); 43 | }); 44 | 45 | it('should render forms correctly', () => { 46 | const { getAllByRole } = render( 47 |
    52 | 53 | {(values) => ( 54 |
      55 | {values.map((value, idx) => ( 56 |
    • 57 | 61 | 62 |
    • 63 | ))} 64 |
    65 | )} 66 |
    67 |
    , 68 | ); 69 | 70 | expect(getAllByRole('textbox')[0].className).toEqual(' invalid'); 71 | }); 72 | 73 | it('should update the form value correctly', async () => { 74 | let value, last; 75 | let changeSpy = vi.fn((v) => (value = v)); 76 | 77 | const { getAllByRole } = render( 78 |
    84 | 85 | {(values) => ( 86 |
      87 | {values.map((value, idx) => ( 88 |
    • 89 | 90 | 94 |
    • 95 | ))} 96 |
    97 | )} 98 |
    99 |
    , 100 | ); 101 | 102 | let inputs = getAllByRole('textbox'); 103 | 104 | fireEvent.change(inputs[0], { target: { value: 'beige' } }); 105 | 106 | expect(changeSpy).toHaveBeenCalledTimes(1); 107 | 108 | expect(value).toEqual({ 109 | colors: [ 110 | { 111 | name: 'beige', 112 | hexCode: '#ff0000', 113 | }, 114 | ], 115 | }); 116 | 117 | last = value; 118 | 119 | fireEvent.change(inputs[1], { target: { value: 'LULZ' } }); 120 | 121 | expect(value).toEqual({ 122 | colors: [ 123 | { 124 | name: 'beige', 125 | hexCode: 'LULZ', 126 | }, 127 | ], 128 | }); 129 | 130 | expect(value).not.toBe(last); 131 | }); 132 | 133 | it('should handle removing array items', async () => { 134 | let value; 135 | let changeSpy = vi.fn((v) => (value = v)); 136 | let defaultValue = { 137 | colors: [ 138 | { name: 'red', hexCode: '#ff0000' }, 139 | { name: 'other red', hexCode: '#ff0000' }, 140 | ], 141 | }; 142 | let ref = React.createRef(); 143 | let { getByRole } = render( 144 |
    150 | 151 | , 152 | ); 153 | 154 | let list = getByRole('list'); 155 | 156 | expect(list.children).toHaveLength(2); 157 | 158 | act(() => { 159 | ref.current.remove(1); 160 | }); 161 | 162 | expect(value).toEqual({ 163 | colors: [ 164 | { 165 | name: 'red', 166 | hexCode: '#ff0000', 167 | }, 168 | ], 169 | }); 170 | }); 171 | 172 | it('should shift errors for removed fields', async () => { 173 | let value, errors; 174 | let errorSpy = vi.fn((v) => (errors = v)); 175 | let changeSpy = vi.fn((v) => (value = v)); 176 | let defaultValue = { 177 | colors: [ 178 | { name: '', hexCode: '#ff0000' }, 179 | { name: 'other red', hexCode: '#ff0000' }, 180 | ], 181 | }; 182 | const ref = React.createRef(); 183 | const ref2 = React.createRef(); 184 | let { getByRole } = render( 185 |
    186 |
    193 | 194 | 195 |
    , 196 | ); 197 | 198 | let list = getByRole('list'); 199 | 200 | expect(list.children).toHaveLength(2); 201 | 202 | await act(() => ref.current.submit()); 203 | 204 | // First color has an error 205 | expect(errors['colors[0].name']).toBeDefined(); 206 | 207 | act(() => { 208 | // remove the first color 209 | ref2.current.remove(0); 210 | }); 211 | // The error for the first color should be gone 212 | expect(errorSpy).toHaveBeenCalledTimes(2); 213 | expect(errors['colors[0].name']).not.toBeDefined(); 214 | 215 | expect(value).toEqual({ 216 | colors: [{ name: 'other red', hexCode: '#ff0000' }], 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /test/Message.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { describe, it, vi, expect } from 'vitest'; 4 | import { render } from '@testing-library/react'; 5 | 6 | import { Form } from '../src'; 7 | 8 | describe('Message', () => { 9 | it('should allow empty for', () => { 10 | let renderSpy = vi.fn((msgs) => { 11 | expect(msgs).toEqual(['hi', 'good day']); 12 | return null; 13 | }); 14 | render( 15 |
    16 |
    17 | {/* @ts-expect-error */} 18 | {renderSpy} 19 |
    20 |
    , 21 | ); 22 | 23 | expect(renderSpy).toHaveBeenCalled(); 24 | }); 25 | 26 | it('should allow group summaries', () => { 27 | let renderSpy = vi.fn((msgs) => { 28 | expect(msgs).toEqual(['foo', 'hi', 'good day']); 29 | return null; 30 | }); 31 | 32 | render( 33 |
    41 |
    42 | 43 | {renderSpy} 44 | 45 |
    46 |
    , 47 | ); 48 | 49 | expect(renderSpy).toHaveBeenCalled(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/NestedForm.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as yup from 'yup'; 3 | import { Form } from '../src'; 4 | import NestedForm from '../src/NestedForm'; 5 | import { describe, it, vi, expect } from 'vitest'; 6 | import { fireEvent, render } from '@testing-library/react'; 7 | 8 | describe('NestedForm', () => { 9 | let schema = yup.object({ 10 | name: yup.object({ 11 | first: yup.string().default(''), 12 | last: yup.string().default(''), 13 | }), 14 | }); 15 | 16 | it('should work', () => { 17 | let value, last; 18 | let change = vi.fn((v) => (value = v)); 19 | 20 | let { getAllByRole } = render( 21 |
    26 |
    27 | 28 | 29 | 30 | 31 |
    32 |
    , 33 | ); 34 | 35 | const [firstInput, lastInput] = getAllByRole('textbox'); 36 | 37 | fireEvent.change(firstInput, { target: { value: 'Jill' } }); 38 | 39 | expect(change).toHaveBeenCalledTimes(1); 40 | 41 | expect(value).toEqual({ 42 | name: { 43 | first: 'Jill', 44 | last: '', 45 | }, 46 | }); 47 | 48 | last = value; 49 | fireEvent.change(lastInput, { target: { value: 'Smith' } }); 50 | 51 | expect(value).toEqual({ 52 | name: { 53 | first: 'Jill', 54 | last: 'Smith', 55 | }, 56 | }); 57 | 58 | expect(value).not.toBe(last); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/Reset.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, vi, expect } from 'vitest'; 3 | import { fireEvent, render } from '@testing-library/react'; 4 | import * as yup from 'yup'; 5 | import { Form } from '../src'; 6 | 7 | describe('Reset', () => { 8 | const schema = yup.object({ testfield: yup.string() }); 9 | 10 | it('should passthrough props', () => { 11 | const { getByRole } = render(); 12 | 13 | expect(getByRole('button').classList.contains('foo')).toEqual(true); 14 | }); 15 | 16 | it('should warn when reset is used outside of Form', () => { 17 | // eslint-disable-next-line @typescript-eslint/no-empty-function 18 | let stub = vi.spyOn(console, 'error').mockImplementation(() => {}); 19 | let spy = vi.fn(); 20 | 21 | let { getByRole } = render(); 22 | 23 | getByRole('button').click(); 24 | 25 | expect(stub).toHaveBeenCalledTimes(1); 26 | 27 | stub.mockRestore(); 28 | }); 29 | 30 | it('should reset to default form values', async () => { 31 | let spy = vi.fn(); 32 | let errorSpy = vi.fn(); 33 | 34 | const { getByRole } = render( 35 |
    36 |
    37 | 38 | 39 |
    40 |
    , 41 | ); 42 | 43 | fireEvent.change(getByRole('textbox'), { 44 | target: { value: 'foo', type: 'string' }, 45 | }); 46 | 47 | expect((getByRole('textbox') as any).value).toBe('foo'); 48 | 49 | fireEvent.click(getByRole('button'), {}); 50 | 51 | expect((getByRole('textbox') as any).value).toBe(''); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/Submit.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, vi, expect } from 'vitest'; 3 | import { fireEvent, render, act, waitFor } from '@testing-library/react'; 4 | import * as yup from 'yup'; 5 | import { Form, useFormSubmit } from '../src'; 6 | import { FormHandle } from '../src/Form'; 7 | 8 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 9 | 10 | describe('Submit', () => { 11 | const schema = yup.object({ fieldA: yup.mixed(), fieldB: yup.mixed() }); 12 | 13 | it('should passthrough props', () => { 14 | let { getByRole } = render(); 15 | 16 | expect(getByRole('button').classList.contains('foo')).toBe(true); 17 | }); 18 | 19 | it('should warn when submit is used outside of Form', () => { 20 | // eslint-disable-next-line @typescript-eslint/no-empty-function 21 | let stub = vi.spyOn(console, 'error').mockImplementation(() => {}); 22 | let spy = vi.fn(); 23 | 24 | let { getByRole } = render(); 25 | 26 | act(() => { 27 | getByRole('button').click(); 28 | }); 29 | expect(spy).toHaveBeenCalledTimes(1); 30 | 31 | expect(stub).toHaveBeenCalledTimes(1); 32 | 33 | stub.mockRestore(); 34 | }); 35 | 36 | it('should simulate event for name', () => { 37 | let spy = vi.fn(); 38 | let { getByRole } = render( 39 |
    40 |
    41 | 42 | {(props) => } 43 | 44 |
    45 |
    , 46 | ); 47 | 48 | fireEvent.change(getByRole('textbox'), { target: { value: 'foo' } }); 49 | 50 | expect(spy).toHaveBeenCalledTimes(1); 51 | expect(spy).toHaveBeenCalledWith( 52 | expect.objectContaining({ fields: ['fieldA'] }), 53 | ); 54 | }); 55 | 56 | it('should simulate event once with multiple names', () => { 57 | let spy = vi.fn(); 58 | let { getByRole } = render( 59 |
    60 |
    61 | 62 |
    63 |
    , 64 | ); 65 | 66 | act(() => { 67 | getByRole('button').click(); 68 | }); 69 | 70 | expect(spy).toHaveBeenCalledTimes(1); 71 | expect(spy).toHaveBeenCalledWith( 72 | expect.objectContaining({ fields: ['fieldA', 'fieldB'] }), 73 | ); 74 | }); 75 | 76 | it('should simulate for `triggers`', async () => { 77 | const spy = vi.fn(({ fields }) => { 78 | expect(fields).toEqual(['fieldA']); 79 | }); 80 | 81 | let { getByRole } = render( 82 |
    83 |
    84 | 85 | {(props) => } 86 | 87 | 88 | {(props) => } 89 | 90 | 91 | 92 |
    93 |
    , 94 | ); 95 | 96 | act(() => { 97 | getByRole('button').click(); 98 | }); 99 | 100 | expect(spy).toHaveBeenCalledTimes(1); 101 | }); 102 | 103 | it('should trigger a submit', async () => { 104 | const spy = vi.fn(); 105 | 106 | let { getByRole } = render( 107 |
    108 |
    109 | 110 | 111 | 112 | 113 | 114 |
    115 |
    , 116 | ); 117 | 118 | fireEvent.click(getByRole('button')); 119 | 120 | await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); 121 | }); 122 | 123 | it('Field should handle submitting state', async () => { 124 | let spy = vi.fn(() => wait(50)); 125 | let ref = React.createRef(); 126 | 127 | let { getByTestId } = render( 128 |
    129 |
    130 |
    131 | 132 | {(_, meta) => ( 133 | 134 | submitting: {String(meta.submitting)} 135 | 136 | )} 137 | 138 |
    139 |
    140 |
    , 141 | ); 142 | 143 | let trigger = getByTestId('span'); 144 | 145 | expect(trigger.textContent).toBe('submitting: false'); 146 | 147 | act(() => { 148 | ref.current!.submit(); 149 | }); 150 | 151 | await waitFor(() => { 152 | expect(trigger.textContent).toBe('submitting: true'); 153 | }); 154 | 155 | await waitFor(() => { 156 | expect(trigger.textContent).toBe('submitting: false'); 157 | }); 158 | }); 159 | 160 | it('Submit should handle submitting state', async () => { 161 | let ref = React.createRef(); 162 | let spy = vi.fn(() => wait(50)); 163 | 164 | function Submit(props) { 165 | const [, { submitting, submitCount }] = useFormSubmit(props); 166 | 167 | return ( 168 | 169 | {String(submitting)}: {String(submitCount)} 170 | 171 | ); 172 | } 173 | 174 | let { getByTestId } = render( 175 |
    176 |
    177 |
    178 | 179 |
    180 |
    181 |
    , 182 | ); 183 | 184 | let trigger = getByTestId('span'); 185 | 186 | expect(trigger.textContent).toBe('false: 0'); 187 | 188 | act(() => { 189 | ref.current!.submit(); 190 | }); 191 | 192 | await waitFor(() => { 193 | expect(trigger.textContent).toBe('true: 0'); 194 | }); 195 | 196 | await waitFor(() => { 197 | expect(trigger.textContent).toBe('false: 1'); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /test/bindings.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define */ 2 | import useUpdateEffect from '@restart/hooks/useUpdateEffect'; 3 | import { describe, it, vi, expect } from 'vitest'; 4 | import { fireEvent, render } from '@testing-library/react'; 5 | import useFormBindingContext, { 6 | BindingContext as Context, 7 | } from '../src/BindingContext'; 8 | import useBinding from '../src/useBinding'; 9 | 10 | import React, { useState, useMemo } from 'react'; 11 | 12 | function BindingContext({ value, onChange, children }: any) { 13 | return ( 14 | null, 19 | })} 20 | > 21 | {children} 22 | 23 | ); 24 | } 25 | 26 | function Binding({ bindTo, mapValue, children }: any) { 27 | const [value, handleEvent] = useBinding(bindTo, mapValue); 28 | 29 | const element = useMemo( 30 | () => children(value, handleEvent), 31 | [value, handleEvent, children], 32 | ); 33 | 34 | return element; 35 | } 36 | 37 | describe('Bindings', () => { 38 | class StaticContainer extends React.Component { 39 | shouldComponentUpdate(props) { 40 | return !!props.shouldUpdate; 41 | } 42 | render() { 43 | return this.props.children; 44 | } 45 | } 46 | 47 | const BoundInput = ({ name }) => { 48 | const [value = '', handleChange] = useBinding(name); 49 | return ; 50 | }; 51 | 52 | it('should update the form value: hook', function () { 53 | let change = vi.fn(); 54 | 55 | let { getByRole } = render( 56 | 57 | 58 | , 59 | ); 60 | 61 | fireEvent.change(getByRole('textbox'), { target: { value: 'Jill' } }); 62 | 63 | expect(change).toHaveBeenCalledTimes(1); 64 | 65 | expect(change).toHaveBeenCalledWith({ name: 'Jill' }, ['name']); 66 | }); 67 | 68 | it('should accept primitive values', function () { 69 | let change = vi.fn(); 70 | 71 | const BoundInput = ({ name }) => { 72 | const [value = '', handleChange] = useBinding(name); 73 | return ( 74 | handleChange(e.target.value)} 78 | /> 79 | ); 80 | }; 81 | 82 | let { getByRole } = render( 83 | 84 | 85 | , 86 | ); 87 | 88 | fireEvent.change(getByRole('textbox'), { target: { value: 'Jill' } }); 89 | 90 | expect(change).toHaveBeenCalledTimes(1); 91 | expect(change).toHaveBeenCalledWith({ name: 'Jill' }, ['name']); 92 | }); 93 | 94 | it('should always update if binding value changed', function () { 95 | let change = vi.fn(); 96 | let value = { name: 'sally', eyes: 'hazel' }; 97 | let count = 0; 98 | 99 | const CountRenders = ({ name }) => { 100 | const [value = '', handleChange] = useBinding(name); 101 | // @ts-ignore 102 | useUpdateEffect(() => { 103 | count++; 104 | }); 105 | return ; 106 | }; 107 | 108 | let { rerender } = render(renderWithValue(value)); 109 | 110 | expect(count).toBe(0); 111 | 112 | rerender(renderWithValue({ ...value, eyes: 'brown' })); 113 | 114 | expect(count).toBe(1); 115 | 116 | rerender(renderWithValue({ ...value, name: 'Sallie' })); 117 | 118 | expect(count).toBe(2); 119 | 120 | function renderWithValue(value) { 121 | return ( 122 | 123 | {/* @ts-ignore */} 124 | 125 | 126 | 127 | 128 | ); 129 | } 130 | }); 131 | 132 | it('should update if props change', function () { 133 | let count = 0; 134 | const CountRenders = ({ name }) => { 135 | const [value = '', handleChange] = useBinding(name); 136 | // @ts-ignore 137 | useUpdateEffect(() => { 138 | count++; 139 | }); 140 | return ; 141 | }; 142 | 143 | let { rerender } = render(); 144 | 145 | expect(count).toBe(0); 146 | 147 | rerender(); 148 | 149 | expect(count).toBe(1); 150 | }); 151 | 152 | it('should not prevent input updates', function () { 153 | let change = vi.fn(); 154 | let value = { name: 'sally', eyes: 'hazel' }; 155 | let count = 0; 156 | 157 | class Input extends React.Component { 158 | componentDidUpdate() { 159 | count++; 160 | } 161 | render = () => ; 162 | } 163 | 164 | class Parent extends React.Component { 165 | render() { 166 | return ( 167 | 168 | 169 | {(value, onChange) => ( 170 | 171 | )} 172 | 173 | 174 | ); 175 | } 176 | } 177 | 178 | let { rerender } = render(); 179 | 180 | expect(count).toBe(0); 181 | 182 | rerender(); 183 | 184 | expect(count).toBe(1); 185 | }); 186 | 187 | it('should batch', async () => { 188 | let ref = { current: null }; 189 | let count = 0; 190 | const Input = () => { 191 | const [, changeA] = useBinding('a'); 192 | const [, changeB] = useBinding('b'); 193 | 194 | return ( 195 | { 198 | changeA('1'); 199 | changeB('2'); 200 | }} 201 | /> 202 | ); 203 | }; 204 | 205 | function Wrapper() { 206 | const [value, setValue] = useState([{ a: 'nope', b: 'nope' }, null]); 207 | ref.current = value; 208 | return ( 209 | { 212 | ++count; 213 | setValue([v, paths]); 214 | }} 215 | > 216 | 217 | 218 | ); 219 | } 220 | 221 | let { getByRole } = render(); 222 | 223 | fireEvent.change(getByRole('textbox'), { target: { value: 'something' } }); 224 | 225 | expect(count).toBe(1); 226 | expect(ref.current).toEqual([ 227 | { 228 | a: '1', 229 | b: '2', 230 | }, 231 | ['a', 'b'], 232 | ]); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/paths.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as paths from '../src/utils/paths'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('PATH utils', () => { 5 | it('should clean part', () => { 6 | expect(paths.clean("'hi'")).toBe('hi'); 7 | expect(paths.clean('hi')).toBe('hi'); 8 | expect(paths.clean('"hi"')).toBe('hi'); 9 | expect(paths.clean('hi')).toBe('hi'); 10 | }); 11 | 12 | it('should check if paths contains', () => { 13 | expect(paths.inPath('a', 'a.b')).toBe(true); 14 | expect(paths.inPath('a', 'b.a')).toBe(false); 15 | 16 | expect(paths.inPath('a[0]', 'a[0].b')).toBe(true); 17 | expect(paths.inPath('a.b', 'a.c')).toBe(false); 18 | expect(paths.inPath('a.b.c', 'a.b.c.d')).toBe(true); 19 | expect(paths.inPath('a.b.c.d', 'a.b.c')).toBe(false); 20 | 21 | expect(paths.inPath('a["b"].c', 'a.b["c"].d')).toBe(true); 22 | }); 23 | 24 | it('should reduce array of paths', () => { 25 | expect(paths.reduce(['a', 'a.b'])).toEqual(['a']); 26 | expect(paths.reduce(['a.b', 'a'])).toEqual(['a']); 27 | 28 | expect(paths.reduce(['a.b.c', 'a.b', 'a.c'])).toEqual(['a.b', 'a.c']); 29 | }); 30 | 31 | it('should trim paths', () => { 32 | let errors = { 33 | 'name': ['invalid'], 34 | 'name.first': ['invalid'], 35 | 'id': ['invalid'], 36 | }; 37 | 38 | expect(paths.trim('name', errors)).not.toBe(errors); 39 | expect(paths.trim('name', errors)).toEqual({ 40 | id: ['invalid'], 41 | }); 42 | }); 43 | 44 | it('should trim paths exactly', () => { 45 | let errors = { 46 | 'name': ['invalid'], 47 | 'name.first': ['invalid'], 48 | 'id': ['invalid'], 49 | }; 50 | 51 | expect(paths.trim('name', errors, true)).not.toBe(errors); 52 | expect(paths.trim('name', errors, true)).toEqual({ 53 | 'name.first': ['invalid'], 54 | 'id': ['invalid'], 55 | }); 56 | }); 57 | 58 | it('should return same object when unchanged', () => { 59 | let errors = { 60 | 'name': ['invalid'], 61 | 'name.first': ['invalid'], 62 | 'id': ['invalid'], 63 | }; 64 | expect(paths.trim('foo', errors)).toBe(errors); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react'; 2 | 3 | import { afterEach, vi } from 'vitest'; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | vi.useRealTimers(); 8 | }); 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web", 3 | "compilerOptions": { 4 | "noImplicitAny": false 5 | }, 6 | "include": [".", "../src"] 7 | } 8 | -------------------------------------------------------------------------------- /test/types-check.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jquense/react-formal/095e4f7c4a18123ef8dc0a59a8fef38ed82019ed/test/types-check.tsx -------------------------------------------------------------------------------- /test/update.test.ts: -------------------------------------------------------------------------------- 1 | import update from '../src/utils/updateIn'; 2 | 3 | describe('immutable setter', () => { 4 | it('should create different objects', () => { 5 | const objA = { foo: [{ bar: { quuz: 10 } }], baz: { quuz: 10 } }; 6 | const objB = update(objA, 'foo[0].bar.quuz', 5); 7 | 8 | expect(objA).not.toBe(objB); 9 | expect(objA.baz).toBe(objB.baz); 10 | expect(objA.foo).not.toBe(objB.foo); 11 | expect(objA.foo[0]).not.toBe(objB.foo[0]); 12 | expect(objA.foo[0].bar).not.toBe(objB.foo[0].bar); 13 | expect(objA.foo[0].bar.quuz).not.toBe(objB.foo[0].bar.quuz); 14 | 15 | expect(objB.foo[0].bar.quuz).toBe(5); 16 | }); 17 | 18 | it('should safely set missing branches', () => { 19 | const objA = { baz: { quuz: 10 } }; 20 | const objB = update(objA, 'foo[0].bar.quuz', 5); 21 | 22 | expect(objA).not.toBe(objB); 23 | expect(objA.baz).toBe(objB.baz); 24 | 25 | // @ts-expect-error 26 | expect(objA.foo).toBe(undefined); 27 | // @ts-expect-error 28 | expect(objB.foo).toEqual([{ bar: { quuz: 5 } }]); 29 | }); 30 | 31 | it('should not try to copy null', () => { 32 | expect(() => update({ foo: null }, 'foo.bar', 5)).not.toThrow(); 33 | }); 34 | 35 | it('should consider null', () => { 36 | expect(update({ foo: null }, 'foo.bar', 5)).toEqual({ 37 | foo: { bar: 5 }, 38 | }); 39 | }); 40 | 41 | it('should work when input is undefined', () => { 42 | const obj = update(void 0, 'foo[0].bar.quuz', 5); 43 | 44 | expect(obj).toEqual({ foo: [{ bar: { quuz: 5 } }] }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "skipLibCheck": true, 5 | "lib": ["esnext", "dom"], 6 | "target": "esnext", 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "allowJs": true, 10 | "allowSyntheticDefaultImports": true, 11 | "downlevelIteration": true, 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "isolatedModules": true, 16 | "stripInternal": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "resolveJsonModule": true, 19 | "strict": true, 20 | "jsx": "react-jsx" 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | dir: resolve('./test'), 8 | globals: true, 9 | setupFiles: ['./test/setup.ts'], 10 | environment: 'jsdom', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /www/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | console.log(path.resolve('../src')); 4 | module.exports = { 5 | extends: '../.eslintrc.yml', 6 | rules: { 7 | 'react/prop-types': 'off', 8 | }, 9 | settings: { 10 | 'import/extensions': ['.js', '.ts', '.tsx'], 11 | 'import/resolver': { 12 | node: {}, 13 | webpack: { 14 | config: { 15 | resolve: { 16 | symlinks: false, 17 | extensions: ['.js', '.ts', '.tsx'], 18 | alias: { 19 | 'react-formal': path.resolve('../src'), 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jquense/react-formal/095e4f7c4a18123ef8dc0a59a8fef38ed82019ed/www/favicon.ico -------------------------------------------------------------------------------- /www/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import './src/styles/global.css'; 2 | import 'react-widgets/lib/scss/react-widgets.scss'; 3 | -------------------------------------------------------------------------------- /www/gatsby-config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const src = path.resolve(__dirname, '../src'); 4 | 5 | module.exports = { 6 | pathPrefix: '/react-formal', 7 | siteMetadata: { 8 | title: 'documentation', 9 | author: 'Jason Quense', 10 | }, 11 | plugins: [ 12 | 'gatsby-plugin-sass', 13 | { 14 | resolve: 'gatsby-plugin-astroturf', 15 | options: { enableCssProp: true }, 16 | }, 17 | { 18 | resolve: `gatsby-plugin-webfonts`, 19 | options: { 20 | fonts: { 21 | google: [ 22 | { 23 | family: 'Abril Fatface', 24 | subsets: ['latin'], 25 | display: 'swap', 26 | }, 27 | { 28 | family: 'Open+Sans', 29 | subsets: ['latin'], 30 | display: 'swap', 31 | }, 32 | ], 33 | }, 34 | }, 35 | }, 36 | { 37 | resolve: '@docpocalypse/gatsby-theme', 38 | options: { 39 | sources: [src], 40 | 41 | theming: 'full', 42 | propsLayout: 'list', 43 | tailwindConfig: require.resolve('./tailwind.config'), 44 | 45 | getImportName(docNode) { 46 | if ( 47 | ['Field', 'FieldArray', 'Message', 'Summary', 'Summary'].includes( 48 | docNode.name, 49 | ) 50 | ) { 51 | return `import Form from '${docNode.packageName}'`; 52 | } 53 | 54 | return `import { ${docNode.name} } from '${docNode.packageName}'`; 55 | }, 56 | }, 57 | }, 58 | // { 59 | // resolve: 'gatsby-plugin-typedoc', 60 | // options: { 61 | // projects: [src], 62 | // }, 63 | // }, 64 | ], 65 | }; 66 | -------------------------------------------------------------------------------- /www/gatsby-node.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | exports.onCreateBabelConfig = ({ actions }) => { 4 | actions.setBabelOptions({ 5 | options: { 6 | babelrcRoots: true, 7 | }, 8 | }); 9 | }; 10 | 11 | exports.onCreateWebpackConfig = function onCreateWebpackConfig({ actions }) { 12 | actions.setWebpackConfig({ 13 | resolve: { 14 | symlinks: false, 15 | alias: { 16 | 'react': path.resolve('./node_modules/react'), 17 | 'react-dom': path.resolve('./node_modules/react-dom'), 18 | 'react-formal': path.resolve('../src'), 19 | '@docs': path.resolve('./src'), 20 | }, 21 | }, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs-site", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "gatsby develop", 7 | "build": "gatsby build" 8 | }, 9 | "dependencies": { 10 | "@docpocalypse/code-live": "^0.4.18", 11 | "@docpocalypse/gatsby-theme": "^0.11.30", 12 | "@docpocalypse/props-table": "^0.3.11", 13 | "astroturf": "^0.10.4", 14 | "gatsby": "^2.21.39", 15 | "gatsby-plugin-astroturf": "^0.2.1", 16 | "gatsby-plugin-sass": "^2.3.1", 17 | "gatsby-plugin-web-font-loader": "^1.0.4", 18 | "gatsby-plugin-webfonts": "^1.1.2", 19 | "patch-package": "^6.2.2", 20 | "polished": "^3.5.2", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "react-widgets": "^5.0.0-beta.9", 24 | "tailwindcss": "^1.4.6", 25 | "usa-states": "^0.0.5" 26 | }, 27 | "devDependencies": { 28 | "sass": "^1.26.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /www/src/@docpocalypse/gatsby-theme/components/ComponentImport.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ComponentImport from '@docpocalypse/gatsby-theme/src/components/ComponentImport'; 3 | 4 | const staticProps = [ 5 | 'Field', 6 | 'FieldArray', 7 | 'Submit', 8 | 'Message', 9 | 'Summary', 10 | 'Summary', 11 | ]; 12 | function StyledComponentImport(props) { 13 | const { docNode } = props; 14 | let importName = `import { ${docNode.name} } from 'react-formal';`; 15 | let subtitle; 16 | 17 | if (staticProps.includes(docNode.name)) { 18 | importName = `import { Form } from 'react-formal';`; 19 | subtitle = ( 20 | 25 | ); 26 | } 27 | 28 | return ( 29 |
    30 | 31 | {subtitle} 32 |
    33 | ); 34 | } 35 | 36 | export default StyledComponentImport; 37 | -------------------------------------------------------------------------------- /www/src/@docpocalypse/gatsby-theme/components/Navbar.js: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import { Link } from 'gatsby'; 3 | import React from 'react'; 4 | import Logo from '../../../components/Logo'; 5 | 6 | function Navbar({ className, bg = 'bg-primary' }) { 7 | return ( 8 |
    15 |
    16 | 20 | 21 | React Formal 22 | 23 | 24 | 35 |
    36 |
    37 | ); 38 | } 39 | 40 | export default Navbar; 41 | -------------------------------------------------------------------------------- /www/src/@docpocalypse/gatsby-theme/components/SideNavigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sortBy from 'lodash/sortBy'; 3 | import groupBy from 'lodash/groupBy'; 4 | import SideNavigation, { 5 | usePageData, 6 | } from '@docpocalypse/gatsby-theme/src/components/SideNavigation'; 7 | 8 | function AppSideNavigation(props) { 9 | const { api } = usePageData(); 10 | 11 | const groupedByMembers = groupBy( 12 | api, 13 | (doc) => doc.tags.find((t) => t.name === 'memberof')?.value || 'none', 14 | ); 15 | 16 | return ( 17 | 18 | 64 | 65 | ); 66 | } 67 | 68 | export default AppSideNavigation; 69 | -------------------------------------------------------------------------------- /www/src/bow-tie.svg: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | image/svg+xml 29 | 30 | 31 | 56 | 62 | 63 | 70 | 77 | 84 | 85 | 86 | 94 | 95 | -------------------------------------------------------------------------------- /www/src/bowtie-main.svg: -------------------------------------------------------------------------------- 1 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /www/src/components/Callout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'astroturf'; 3 | 4 | function Callout(props) { 5 | return ( 6 |