├── .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 | [](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 |
Submit
56 |
57 | )
58 | }
59 | ```
60 |
--------------------------------------------------------------------------------
/assets/bowtie-fav.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
--------------------------------------------------------------------------------
/assets/triangle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
--------------------------------------------------------------------------------
/assets/tux-outline.svg:
--------------------------------------------------------------------------------
1 |
35 |
--------------------------------------------------------------------------------
/assets/tux-simple.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
--------------------------------------------------------------------------------
/assets/tux.svg:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/assets/tux2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 ``.
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 |
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 |
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 |
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 | ,
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 |
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 | {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 |
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 | ,
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
95 |
--------------------------------------------------------------------------------
/www/src/bowtie-main.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/www/src/components/Callout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from 'astroturf';
3 |
4 | function Callout(props) {
5 | return (
6 |
23 | );
24 | }
25 |
26 | export default Callout;
27 |
--------------------------------------------------------------------------------
/www/src/components/FormWithErrors.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Form, { formStatics } from 'react-formal';
3 | import Result from './Result';
4 |
5 | function FormWithErrors({ children, ...props }) {
6 | return (
7 |
11 | );
12 | }
13 |
14 | export default Object.assign(FormWithErrors, formStatics);
15 |
--------------------------------------------------------------------------------
/www/src/components/FormWithResult.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Form, { formStatics } from 'react-formal';
3 | import Result from './Result';
4 |
5 | function FormWithResult({ children, ...props }) {
6 | return (
7 |
11 | );
12 | }
13 |
14 | export default Object.assign(FormWithResult, formStatics);
15 |
--------------------------------------------------------------------------------
/www/src/components/IconButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function IconButton(props) {
4 | return (
5 |
6 | {props.children}
7 |
8 | );
9 | }
10 |
11 | export default IconButton;
12 |
--------------------------------------------------------------------------------
/www/src/components/Logo.js:
--------------------------------------------------------------------------------
1 | import cn from 'classnames';
2 | import React from 'react';
3 |
4 | function Logo(props) {
5 | return (
6 |
28 | );
29 | }
30 |
31 | export default Logo;
32 |
--------------------------------------------------------------------------------
/www/src/components/Pre.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { CodeBlock, LiveCode, useScope } from '@docpocalypse/gatsby-theme'
3 | import { toText } from '@docpocalypse/gatsby-theme/src/components/Pre.tsx'
4 | import styles from '../styles/prism.module.css'
5 |
6 | const Pre = props => {
7 | const scope = useScope()
8 | const {
9 | children,
10 | originalType: _1,
11 | metastring: _2,
12 | mdxType: _3,
13 | parentName: _4,
14 | ...codeProps
15 | } = props.children.props
16 |
17 | return codeProps.editable ? (
18 |
19 | ) : (
20 |
24 | {toText(children)}
25 |
26 | )
27 | }
28 |
29 | export default Pre
30 |
--------------------------------------------------------------------------------
/www/src/components/Result.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { CodeBlock } from '@docpocalypse/code-live';
3 | import { syntaxTheme } from '@docpocalypse/gatsby-theme';
4 |
5 | // eslint-disable-next-line import/no-unresolved
6 | import { useErrors, useFormValues } from 'react-formal';
7 | import { css } from 'astroturf';
8 |
9 | const propTypes = {};
10 |
11 | function Result({ value, showErrors, ...rest }) {
12 | const formValue = useFormValues(m => m);
13 | const errors = useErrors();
14 |
15 | const showValue = showErrors
16 | ? errors
17 | : value === undefined
18 | ? formValue
19 | : value;
20 |
21 | return showValue ? (
22 |
40 |
45 |
46 | ) : null;
47 | }
48 |
49 | Result.propTypes = propTypes;
50 |
51 | export default Result;
52 |
--------------------------------------------------------------------------------
/www/src/demos/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-alert": "off",
4 | "no-unused-expressions": "off",
5 | "no-unused-vars": "off",
6 | "react/jsx-no-undef": "off",
7 | "react/no-multi-comp": "off"
8 | },
9 | "globals": {
10 | "render": false,
11 | "classNames": false,
12 | "React": false,
13 | "yup": false,
14 | "Form": false
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/www/src/demos/Form.js:
--------------------------------------------------------------------------------
1 | const defaultStr = yup.string().default('')
2 |
3 | const customerSchema = yup.object({
4 | name: yup.object({
5 | first: defaultStr.required('please enter a first name'),
6 |
7 | last: defaultStr.required('please enter a surname'),
8 | }),
9 |
10 | dateOfBirth: yup.date().max(new Date(), 'Are you a time traveler?!'),
11 |
12 | colorId: yup
13 | .number()
14 | .nullable()
15 | .required('Please select a dank color'),
16 | })
17 |
18 | render(
19 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Submit
43 |
44 | )
45 |
--------------------------------------------------------------------------------
/www/src/demos/intro.js:
--------------------------------------------------------------------------------
1 | const Form = require('react-formal');
2 | const { object, string, number, date } = require('yup');
3 |
4 | const modelSchema = object({
5 | name: object({
6 | first: string().required('please enter a first name'),
7 | last: string().required('please enter a surname'),
8 | }),
9 |
10 | dateOfBirth: date().max(new Date(), "You can't be born in the future!"),
11 |
12 | colorId: number()
13 | .nullable()
14 | .required('Please select a color'),
15 | });
16 |
17 | render(
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Submit
43 | ,
44 | );
45 |
--------------------------------------------------------------------------------
/www/src/examples/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jquense/react-formal/095e4f7c4a18123ef8dc0a59a8fef38ed82019ed/www/src/examples/.gitkeep
--------------------------------------------------------------------------------
/www/src/examples/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "printWidth": 70,
4 | "singleQuote": true
5 | }
6 |
--------------------------------------------------------------------------------
/www/src/examples/Field.mdx:
--------------------------------------------------------------------------------
1 | The Field Component renders a form control and handles input value updates and validations.
2 | Changes to the `` value are automatically propagated back up to the containing Form
3 | Component.
4 |
5 | ## Overview
6 |
7 | In the simplest cases ``s provide a light abstraction over normal input components. Fields
8 | shouls provide a `name` mapping the input to a branch of the central form data.
9 | Providing values and onChange handlers is taken take care of, as well as basic
10 | value coalescing for multiple selects and checkbox groups.
11 |
12 | ```jsx
13 | import Form from '@docs/components/FormWithResult';
14 |
15 | ;
35 | ```
36 |
37 | You can manually control the type and sort of input via the `as` prop (for textareas and selects),
38 | and the `type` prop for `inputs` same as with plain HTML inputs. Fields always
39 | provide `value` and `onChange` props to their inputs.
40 |
41 | ## Checkbox and Radios
42 |
43 | Create a unified set of checkbox/radio input's that map to a single form field
44 | by using multiple ``s with the same `name`.
45 |
46 | ```jsx
47 | import Form from '@docs/components/FormWithResult';
48 |
49 | ;
66 | ```
67 |
68 | Use groups of checkboxes to represent list values. React Formal will intelligently
69 | insert or remove items if the current field value is an `array` or, absent a
70 | value, the schema for the field is a `yup.array()`.
71 |
72 | ```jsx
73 | import Form from '@docs/components/FormWithResult';
74 |
75 | ;
98 | ```
99 |
100 | ## Custom components
101 |
102 | Fields are not limited to native input components. You can pass _any_ component
103 | type to `as`. The only required interface a component needs to respect is the
104 | `value`/`onChange` pattern for controlled fields.
105 |
106 | ```jsx previewClassName=reset
107 | import Form from '../components/FormWithResult';
108 |
109 | import DropdownList from 'react-widgets/lib/DropdownList';
110 |
111 | ;
121 | ```
122 |
123 | For a better typed experience with TypeScript, consider using the
124 | [render prop API instead](#children).
125 |
126 | ```jsx static
127 | import Form from '../components/FormWithResult';
128 |
129 | import DropdownList from 'react-widgets/lib/DropdownList';
130 |
131 | ;
144 | ```
145 |
146 | In addition to injecting `` components with events and the field `value`, a
147 | special prop called `meta` is also provided to all Field renderer components. `meta`
148 | contains helpful context and methods for doing manual field operations.
149 |
150 | ```ts static
151 | interface FieldMeta {
152 | value: any; // the Field Value
153 | valid: boolean; // Whether the field is currently valid
154 | invalid: boolean; // inverse of valid
155 | touched: boolean: // whether the field has been touched yet
156 | errors: Errors; // the errors for this field
157 | schema?: YupSchema; // the schema for this field
158 | context?: Record; // a yup context object
159 |
160 | nativeTagName: 'input' | 'select'; // The inferred native HTML element.
161 | nativeType: string; // The inferred HTML input type, only valid for 'input's
162 |
163 | // onError allows manually _replacing_ errors for the Field `name`
164 | // any existing errors for this path will be removed first
165 | onError(errors: Errors): void
166 | // The same callback passed to field components
167 | // for updating (and validating) a field value
168 | onChange(nextFieldValue: any): void
169 |
170 |
171 | }
172 | ```
173 |
174 | ## Validation
175 |
176 | Field validation is automatically enabled for Fields with cooresponding Form schema.
177 | Fields inject `onChange` and `onBlur` handlers to fire a validation.
178 | Field validation is debounced (see [Form delay]('/api/Form#delay')) to reduce unnecessary
179 | checks while the user is still engaging with the input. Validation can be
180 | disabled per field with the `noValidate` prop.
181 |
182 | ### Trigger Events
183 |
184 | Validation trigger events can be finely controlled via the [`validateOn`](#validateOn) prop.
185 | Events control which handlers `` passes to the input it renders. Multiple triggers
186 | can be configured using an object configuration.
187 |
188 | ```tsx
189 | import * as yup from 'yup';
190 | import { Form } from 'react-formal';
191 |
192 | const schema = yup.object({
193 | name: yup.string().required().min(4),
194 | });
195 |
196 | <>
197 |
203 |
204 |
213 |
214 | >;
215 | ```
216 |
217 | For more complex situations `validateOn` accepts a function that is based the `meta` for the field
218 | and can conditionally return events based on context.
219 |
220 | ```tsx
221 | import * as yup from 'yup';
222 | import { Form } from 'react-formal';
223 |
224 | const schema = yup.object({
225 | email: yup
226 | .string()
227 | .email('Emails must contain an @ and a domain')
228 | .required('Required'),
229 | });
230 |
231 | // Only run validation onChange when the form is invalid
232 | const onBlurThenChangeAndBlur = (meta) => ({
233 | change: !meta.valid,
234 | blur: true,
235 | });
236 |
237 |
243 | ;
244 | ```
245 |
246 | ### Preset Strategies
247 |
248 | As a convenience React Formal exports a few common trigger configurations
249 | you can mix and match if that is helpful.
250 |
251 | ```jsx static
252 | import { ValidateStrategies } from 'react-formal';
253 |
254 | const { Change, Blur, ChangeAndBlur, BlurThenChangeAndBlur } =
255 | ValidateStrategies;
256 |
257 |
263 | ;
264 | ```
265 |
266 | There is nothing special about these strategies, and you can roll your own easily.
267 | These are provied as a small convenience.
268 |
--------------------------------------------------------------------------------
/www/src/examples/FieldArray.mdx:
--------------------------------------------------------------------------------
1 | `` that helps with list manipulations.
2 | Fields representing arrays have an additional layer of complexity, since array items may
3 | be reordered, added, or removed as well as updated. `` helps ensure
4 | that errors and other metadata move with the data during list manipulations.
5 |
6 | ## Overview
7 |
8 | Provide a `name` mapping to an array property of the form data and `` will
9 | inject a set of `arrayHelpers` for handling removing, reordering,
10 | editing and adding new items, as well as any error handling quirks that come with those
11 | operations.
12 |
13 | ```jsx
14 | import * as yup from 'yup';
15 | import Form from '../components/FormWithResult';
16 | import IconButton from '../components/IconButton';
17 |
18 | let cid = 0;
19 | const friend = yup.object({
20 | id: yup.number().default(() => cid++),
21 | name: yup.string().required('Required').default(''),
22 | });
23 |
24 | const schema = yup.object({
25 | friends: yup
26 | .array()
27 | .of(friend)
28 | .min(1, 'Must have at least one friend')
29 | .max(4, 'That is too many friends'),
30 | });
31 |
32 |
47 |
48 | {(values, arrayHelpers, meta) => (
49 | <>
50 |
77 | {!values.length && (
78 |
81 | )}
82 | >
83 | )}
84 |
85 | ;
86 | ```
87 |
88 | ## Array helpers
89 |
90 | FieldArray injects a set of `ArrayHelpers` that contain the following methods:
91 |
92 | ```ts static
93 | interface FieldArrayHelpers {
94 | /** Add an item to the beginning of the array */
95 | unshift(item: T): void;
96 |
97 | /** Add an item to the end of the array */
98 | push(item: T): void;
99 |
100 | /** Insert an item at the provided index */
101 | insert(item: T, index: number): void;
102 |
103 | /** Move an item to a new index */
104 | move(item: T, toIndex: number): void;
105 |
106 | /** Remove an item from the list */
107 | remove(item: T): void;
108 |
109 | /**
110 | * update or replace an item with a new one,
111 | * should generally be avoided in favor of using a inner Field
112 | * for the item to handle updates.
113 | *
114 | * Also triggers validation for the _item_
115 | */
116 | update(item: T, oldItem: T): void;
117 | }
118 | ```
119 |
120 | Each method is similar to a Field's onChange handler, updating and validating
121 | the array field.
122 |
123 | ## Validation
124 |
125 | Validation works a bit differently for FieldArrays as compared to normal Fields. Normally
126 | when a field value changes validation is triggered for that path _as well as any nested paths:_
127 |
128 | ```js static
129 | import { object, string, array } from 'yup';
130 |
131 | const schema = object({
132 | friends: array()
133 | .of(
134 | object({
135 | name: string().required('Required'),
136 | })
137 | )
138 | .min(1, 'You need at least one friend'),
139 | });
140 | ```
141 |
142 | A normal Field trigger validation for `friends` produces two
143 | errors: `"Required"` and `"You need at least one friend"`. This behavior works well for object fields.
144 | Hover, it's a bit confusing if creating a new "friend" triggers validation before
145 | the user makes any changes to it.
146 |
147 | To address this, `FieldArray`s **only report errors for array itself**. Meaning
148 | `arrayHelpers.add()` will check if the `min` for "friends" is correct, but not
149 | if the newly added item is valid.
150 |
151 | > **Note:** validation via the schema is still run for the entire `friends` branch,
152 | > but child errors are discarded and not added to form errors. This is only relevant
153 | > if each field performs some expensive validation.
154 |
155 |
162 |
--------------------------------------------------------------------------------
/www/src/examples/Form.mdx:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | Form component renders a `value` to be updated and validated by child Fields.
4 | Forms can be thought of as ``s for complex values, or models. A Form aggregates
5 | a bunch of smaller inputs, each in charge of updating a small part of the overall model.
6 | The Form will integrate and validate each change and fire a single unified `onChange` with the new `value`.
7 |
8 | Validation errors can be displayed anywhere inside a Form with Message Components.
9 |
10 | ```jsx
11 | import Form from '@docs/components/FormWithResult';
12 | import * as yup from 'yup';
13 |
14 | const defaultStr = yup.string().default('');
15 |
16 | const customerSchema = yup.object({
17 | name: yup.object({
18 | first: defaultStr.required('please enter a first name'),
19 | last: defaultStr.required('please enter a surname'),
20 | }),
21 |
22 | dateOfBirth: yup
23 | .date()
24 | .max(new Date(), 'Are you a time traveler?!'),
25 |
26 | colorId: yup
27 | .number()
28 | .nullable()
29 | .required('Please select a dank color'),
30 | });
31 |
32 |
54 |
55 |
65 |
66 |
67 | Submit
68 | ;
69 | ```
70 |
71 | ## Schema
72 |
73 | A schema is not _strictly_ required, however describing form data with a schema
74 | offers many benefits that make their use worthwhile.
75 |
76 | - Schema provide an expressive language for describing validation at a
77 | field level as well as form-wide tests.
78 | - Schema provide type metadata that React Formal can use to simplify configuration.
79 | `yup.array()` fields are automatically render `