├── .commitlintrc.js ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── release.config.js ├── rollup.config.mjs ├── src ├── configuration │ └── addField.ts ├── constants │ └── validationTrigger.ts ├── fields │ ├── Field.ts │ ├── FieldCreator.ts │ ├── FieldCreatorInstance.ts │ ├── RawField.ts │ ├── SelectField.ts │ ├── TextAreaField.ts │ └── input │ │ ├── CheckboxField.ts │ │ ├── InputField.ts │ │ ├── RadioField.ts │ │ └── TextField.ts ├── form-config │ ├── FormArrayConfig.ts │ ├── FormArrayConfigHelper.ts │ ├── FormConfig.ts │ ├── FormConfigHelper.ts │ └── FormCreators.ts ├── hooks │ ├── fluent-form-array │ │ ├── state-manager │ │ │ ├── reducer.ts │ │ │ └── useFluentArrayStateManager.ts │ │ ├── useFluentFormArray.ts │ │ ├── useFluentFormArrayBase.ts │ │ └── useFluentFormItem.ts │ ├── fluent-form │ │ ├── state-manager │ │ │ ├── reducer.ts │ │ │ └── useFluentStateManager.ts │ │ ├── useFluentForm.ts │ │ └── useFluentFormBase.ts │ └── helper │ │ ├── useEffectIgnoreFirst.ts │ │ ├── useHandleSubmit.ts │ │ └── useStateManagerMapper.ts ├── index.ts ├── types.ts ├── utils │ ├── isValidateFunction.ts │ ├── isYupSchema.ts │ ├── isZodSchema.ts │ └── stateUtils.ts └── validation │ ├── DefaultValidator.ts │ └── Validator.ts ├── test ├── DefaultValidator.test.ts ├── FormArrayConfigHelper.test.ts ├── FormConfigHelper.test.ts ├── setupTests.ts ├── test-helper │ ├── CustomField.ts │ └── RequiredValidator.ts ├── test-utils │ ├── renderWithFluentForm.tsx │ ├── renderWithFluentItems.tsx │ └── setTrigger.ts ├── types.ts ├── useFluentForm-fields.test.tsx ├── useFluentForm-functions.test.tsx ├── useFluentForm-validation.test.tsx ├── useFluentForm.test.tsx ├── useFluentFormArray.test.tsx ├── useFluentFormItem-multiple.test.tsx ├── useFluentFormItem-single.test.tsx └── useFluentFormItem.test.tsx ├── tsconfig.json └── tsconfig.package.json /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "type-enum": [ 5 | 2, 6 | "always", 7 | [ 8 | "feat", 9 | "fix", 10 | "test", 11 | "refactor", 12 | "perf", 13 | "docs", 14 | "ci", 15 | "chore", 16 | "build", 17 | ], 18 | ], 19 | "scope-enum": [ 20 | 2, 21 | "always", 22 | [ 23 | "config", 24 | "dev", 25 | "form", 26 | "form-array", 27 | "form-item", 28 | "fields", 29 | "typings", 30 | "readme", 31 | ], 32 | ], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | extends: [ 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | ], 8 | plugins: ["react-hooks"], 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: "module", 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | }, 16 | rules: { 17 | "@typescript-eslint/explicit-function-return-type": "off", 18 | "@typescript-eslint/explicit-module-boundary-types": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/ban-types": "off", 21 | "react-hooks/rules-of-hooks": "error", 22 | "react-hooks/exhaustive-deps": "warn", 23 | "react/prop-types": "off", 24 | }, 25 | settings: { 26 | react: { 27 | version: "detect", 28 | }, 29 | }, 30 | overrides: [ 31 | { 32 | files: ["./test/**/*"], 33 | rules: { 34 | "@typescript-eslint/no-non-null-assertion": "off", 35 | }, 36 | }, 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: ysfaran 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: ysfaran 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen including code examples. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | jobs: 9 | Build_Test_Release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | persist-credentials: false 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "18" 18 | - run: npm install 19 | - run: npm run build:package 20 | - run: npm run test:package 21 | - name: Coveralls 22 | env: 23 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 24 | run: npm run coveralls 25 | - name: Release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | run: npx semantic-release 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | - "/*" 8 | - "**" 9 | - "!master" 10 | 11 | jobs: 12 | Build_Test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: "18" 19 | - run: npm install 20 | - run: npm run build:package 21 | - run: npm run test:package 22 | - name: Coveralls 23 | env: 24 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 25 | run: npm run coveralls 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | tmp -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | }; 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.7.0](https://github.com/ysfaran/react-fluent-form/compare/v1.6.0...v1.7.0) (2023-12-17) 2 | 3 | 4 | ### Features 5 | 6 | * **form:** add support for Zod in the DefaultValidator ([4414fc2](https://github.com/ysfaran/react-fluent-form/commit/4414fc2c75238c09deb128ebef7a25e41cdf32f1)) 7 | 8 | ## [1.6.0](https://github.com/ysfaran/react-fluent-form/compare/v1.5.4...v1.6.0) (2021-11-07) 9 | 10 | ### Features 11 | 12 | - **typings:** update yup types ([7efb239](https://github.com/ysfaran/react-fluent-form/commit/7efb239c3fe490d074b58672cdadd34f75d18eed)) 13 | 14 | ### Bug Fixes 15 | 16 | - **config:** fix vulnerabilities ([4dbe084](https://github.com/ysfaran/react-fluent-form/commit/4dbe084fa848e876a6ec373d2bdebd524dbeec23)) 17 | 18 | ### [1.5.4](https://github.com/ysfaran/react-fluent-form/compare/v1.5.3...v1.5.4) (2020-12-08) 19 | 20 | ### Bug Fixes 21 | 22 | - **config:** add react and react-dom version ^17.0.1 to peerDependencies ([e18cfec](https://github.com/ysfaran/react-fluent-form/commit/e18cfecacecb838f4c1dcf327e9aabc4ef4e4629)) 23 | 24 | ### Documentation 25 | 26 | - add code of conduct ([ae40123](https://github.com/ysfaran/react-fluent-form/commit/ae401237bddbe47742b1791f890b19c96004aa35)) 27 | - unify heading sizes in changelog ([a3a7ae5](https://github.com/ysfaran/react-fluent-form/commit/a3a7ae54ce57a72da2567f705b8249d4b29c8813)) 28 | 29 | ### [1.5.3](https://github.com/ysfaran/react-fluent-form/compare/v1.5.2...v1.5.3) (2020-05-15) 30 | 31 | ### Documentation 32 | 33 | - **readme:** use pretty production url for docs ([9ac3b48](https://github.com/ysfaran/react-fluent-form/commit/9ac3b48806eec3037a9083158a862f972ea14894)) 34 | 35 | ### [1.5.2](https://github.com/ysfaran/react-fluent-form/compare/v1.5.1...v1.5.2) (2020-05-15) 36 | 37 | ### Documentation 38 | 39 | - **readme:** use temporary docs url ([b5ecf61](https://github.com/ysfaran/react-fluent-form/commit/b5ecf61e3ee7bd970d2da43ebe45309aaf2e2796)) 40 | 41 | ### [1.5.1](https://github.com/ysfaran/react-fluent-form/compare/v1.5.0...v1.5.1) (2020-04-24) 42 | 43 | ### Documentation 44 | 45 | - **readme:** move docs to external site ([b843b2d](https://github.com/ysfaran/react-fluent-form/commit/b843b2d7b667e18c6332b8b4daee92c9cac262b7)) 46 | 47 | ## [1.5.0](https://github.com/ysfaran/react-fluent-form/compare/v1.4.1...v1.5.0) (2020-03-31) 48 | 49 | ### Features 50 | 51 | - **form:** add possibility to trigger validation for one or all fields ([d5aa498](https://github.com/ysfaran/react-fluent-form/commit/d5aa498ebef19592a07742ef841ca941b6b37f8b)) 52 | 53 | ### [1.4.1](https://github.com/ysfaran/react-fluent-form/compare/v1.4.0...v1.4.1) (2020-03-31) 54 | 55 | ### Documentation 56 | 57 | - **readme:** add link to introductional blog post ([abd79b9](https://github.com/ysfaran/react-fluent-form/commit/abd79b985b2cd3d47959dd6fe53742878c4811a6)) 58 | - **readme:** improve readme ([2977a02](https://github.com/ysfaran/react-fluent-form/commit/2977a020c4092e0016c7a5265235e71ddb21aa9c)) 59 | - rename CONTRIBUTE.md to CONTRIBUTING.md ([30e2ce4](https://github.com/ysfaran/react-fluent-form/commit/30e2ce423f9c2905d4a0888b7725fcbe8902744c)) 60 | 61 | ## [1.4.0](https://github.com/ysfaran/react-fluent-form/compare/v1.3.0...v1.4.0) (2020-03-25) 62 | 63 | ### Features 64 | 65 | - **fields:** add option to rename mapped raw properties ([d8df8a7](https://github.com/ysfaran/react-fluent-form/commit/d8df8a7aebbb1e5981e021687445988e0ad315bd)) 66 | 67 | ## [1.3.0](https://github.com/ysfaran/react-fluent-form/compare/v1.2.5...v1.3.0) (2020-03-23) 68 | 69 | ### Features 70 | 71 | - **form-array:** add possibility to set initial values and reset ([7915941](https://github.com/ysfaran/react-fluent-form/commit/7915941b397b6b10e7cc2ca0b510379d526f85a6)) 72 | 73 | ### [1.2.5](https://github.com/ysfaran/react-fluent-form/compare/v1.2.4...v1.2.5) (2020-03-19) 74 | 75 | ### Documentation 76 | 77 | - **readme:** add documentation for form arrays ([56105c7](https://github.com/ysfaran/react-fluent-form/commit/56105c7813737111ab1de39038a655fec4c08a20)) 78 | - **readme:** fix wrong example for radio field ([63744d8](https://github.com/ysfaran/react-fluent-form/commit/63744d8b14d4a143a6191f6f3c28ebbab6b3dccf)) 79 | 80 | ### [1.2.4](https://github.com/ysfaran/react-fluent-form/compare/v1.2.3...v1.2.4) (2020-03-17) 81 | 82 | ### Bug Fixes 83 | 84 | - **form-config:** change context type from `any` to `object` ([6cd7f87](https://github.com/ysfaran/react-fluent-form/commit/6cd7f876433223162216c4b845a2e963db43fdec)) 85 | 86 | ### [1.2.3](https://github.com/ysfaran/react-fluent-form/compare/v1.2.2...v1.2.3) (2020-03-17) 87 | 88 | ### Bug Fixes 89 | 90 | - **config:** remove vulnerabilities ([1e49f3f](https://github.com/ysfaran/react-fluent-form/commit/1e49f3f238f5d312ae426a8e3431a058fe1cad8c)) 91 | 92 | ### [1.2.2](https://github.com/ysfaran/react-fluent-form/compare/v1.2.1...v1.2.2) (2020-03-16) 93 | 94 | ### Bug Fixes 95 | 96 | - **hooks:** add form items after initial array ([4c83a07](https://github.com/ysfaran/react-fluent-form/commit/4c83a07e817a0fae2164c29df938c1fe2755d7d2)) 97 | 98 | ### [1.2.1](https://github.com/ysfaran/react-fluent-form/compare/v1.2.0...v1.2.1) (2020-03-13) 99 | 100 | ## [1.2.0](https://github.com/ysfaran/react-fluent-form/compare/v1.1.0...v1.2.0) (2020-03-13) 101 | 102 | ### Features 103 | 104 | - **hooks:** add support for form arrays ([94bb300](https://github.com/ysfaran/react-fluent-form/commit/94bb30063f41071974905ee9cd22c7b724f3af02)), closes [#11](https://github.com/ysfaran/react-fluent-form/issues/11) 105 | 106 | ## [1.1.0](https://github.com/ysfaran/react-fluent-form/compare/v1.0.1...v1.1.0) (2020-03-02) 107 | 108 | ### Features 109 | 110 | - **typings:** adapt errors object to have all fields of values type ([69ad577](https://github.com/ysfaran/react-fluent-form/commit/69ad5773826af4af14f77542bef1e46b17105ca9)) 111 | 112 | ### [1.0.1](https://github.com/ysfaran/react-fluent-form/compare/v1.0.0...v1.0.1) (2020-02-13) 113 | 114 | ### Bug Fixes 115 | 116 | - remove all external dependencies ([a179ac7](https://github.com/ysfaran/react-fluent-form/commit/a179ac7aed7578a446f1788f246e49a02ce43999)) 117 | 118 | ## 1.0.0 (2020-02-12) 119 | 120 | ### Features 121 | 122 | - inital project ([755a279](https://github.com/ysfaran/react-fluent-form/commit/755a279102304fce4951e50fe7ce07010a39060a)) 123 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at yusuf.aran@outlook.de. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | 1. Fork the project 4 | 2. Create a new branch from `master` 5 | - For features: `feature/` 6 | - For fixes: `hotfix/` 7 | - Everything that is not a fix is considered as a feature here 8 | 3. Do changes on the new branch 9 | 4. Execute `npm run test:package` to **verify that your changes didn't break any tests** 10 | 5. Commit changes 11 | 12 | - Commit messages **need** follow the [Conventional Commit Specification](https://www.conventionalcommits.org/) 13 | - Allowed types: 14 | | **type** | **description** | **release** | 15 | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | 16 | | `feat` | any kind of feature that affects users | `MINOR` | 17 | | `fix` | any kind of bug fix that affects users | `PATCH` | 18 | | `test` | test file/configuration only changes (ideally `feat` and `fix` commits already come with tests, but they don't have to) | | 19 | | `refactor` | refactoring code (should never contain features or bug fixes!) | | 20 | | `perf` | changes that improve the performance | `PATCH` | 21 | | `docs` | markdown file changes | `PATCH`, when scope is `readme` | 22 | | `ci` | fixing or improving CI/CD (e.g. adapting files in `.github/workflows/` ) | | 23 | | `chore` | no production code changes, which can not be described with types like `ci` or `build` (e.g. adapting `.gitignore` or adding new script to `package.json` ) | | 24 | | `build` | changes that affect the build (e.g. adapting `rollup.config.js` or adding/removing dependencies) | `PATCH` | 25 | - Allowed scopes: 26 | | **scope** | **description** | 27 | |---------------|---------------------------------------------------------------------------------------------------| 28 | | `config` | adapting config files outside of `src/` (usually they are in the root folder) | 29 | | `dev` | changes that only affect developers/contributors of this project (e.g. adapting commitlint rules) | 30 | | `form` | adapting/adding code related to single form (e.g. `useFluentForm`) | 31 | | `form-array` | adapting/adding code related to form arrays (e.g. `useFluentFormArray`) | 32 | | `form-item` | adapting/adding code related to form items of an array (e.g. `useFluentFormItem`) | 33 | | `fields` | adapting/adding fields | 34 | | `typings` | adapting/adding typings | 35 | | `readme` | adapting `README.md`, should only be used with type `docs` | 36 | - If a commit addresses **more than one scope**, the scope should be the **most relevant one** for that commit (e.g. you added a new configuration option for hooks, but you just needed to make a minor change to the hook itself, then the scope should be `form-config` not `hooks`) 37 | - The scope **can be omitted** when the commit addresses **too many** scopes in the same way 38 | - Feel free to suggest more types and scopes! - See commit history for examples 39 | 40 | 6. Push branch and create pull request to `master` branch 41 | 7. Wait for comment/approval of maintainers 42 | 8. Good job and thank you very much! You just contributed! 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yusuf Aran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | react-fluent-form-logo 3 |

4 | 5 |

6 | react-fluent-form 7 |

8 | 9 |

10 | 11 | Current Release 12 | 13 | 14 | 15 | Build Status 16 | 17 | 18 | 19 | Coverage Status 20 | 21 | 22 | 23 | Licence 24 | 25 | 26 | 27 | Netlify Status 28 | 29 |

30 | 31 | This library was heavily inspired by [useFormState](https://www.npmjs.com/package/react-use-form-state) and [Formik](https://www.npmjs.com/package/formik), which are great libraries on their own! `react-fluent-form` aimes to provide a different API and additional features. 32 | 33 | For a quick introduction you can read this [blog post](https://dev.to/ysfaran/react-fluent-form-how-to-write-forms-with-validation-in-few-steps-56ho)! 34 | 35 | Check out the [official docs](https://react-fluent-form.com). 36 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest"; 2 | 3 | export const config: JestConfigWithTsJest = { 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | collectCoverageFrom: ["src/**/*.+(ts|tsx)", "!src/index.ts"], 7 | setupFilesAfterEnv: ["/test/setupTests.ts"], 8 | transform: { 9 | "^.+\\.tsx?$": [ 10 | "ts-jest", 11 | { 12 | tsconfig: { 13 | noUnusedLocals: false, 14 | }, 15 | }, 16 | ], 17 | }, 18 | } as JestConfigWithTsJest; 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fluent-form", 3 | "version": "1.7.0", 4 | "description": "Describe your forms fluently", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "module": "dist/index.es.js", 8 | "jsnext:main": "dist/index.es.js", 9 | "author": "Yusuf Aran ", 10 | "license": "MIT", 11 | "repository": "ysfaran/react-fluent-form", 12 | "homepage": "https://github.com/ysfaran/react-fluent-form/", 13 | "bugs": { 14 | "url": "https://github.com/ysfaran/react-fluent-form/issues" 15 | }, 16 | "scripts": { 17 | "prepare": "husky install", 18 | "clean:dist": "rimraf dist", 19 | "clean:tmp": "rimraf tmp", 20 | "clean": "npm-run-all clean:dist clean:tmp", 21 | "build": "rollup -c", 22 | "build:watch": "rollup -c -w", 23 | "build:package": "npm-run-all clean build clean:tmp", 24 | "prettier-watch": "onchange \"**/*\" --exclude dist/**/* --exclude node_modules/**/* -- prettier --write {{changed}}", 25 | "start": "npm-run-all --parallel build:watch prettier-watch", 26 | "test": "jest", 27 | "test:watch": "jest --watchAll", 28 | "test:lint": "eslint \"src/**\" \"test/**\"", 29 | "test:coverage": "jest --coverage", 30 | "test:package": "npm-run-all test:lint test:coverage", 31 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 32 | }, 33 | "files": [ 34 | "dist" 35 | ], 36 | "peerDependencies": { 37 | "react": "^16.8.0 || ^17.0.1 || ^18.2.0", 38 | "react-dom": "^16.8.0 || ^17.0.1 || ^18.2.0", 39 | "yup": "^0.32.11 || ^1.3.2", 40 | "zod": "^3.22.4" 41 | }, 42 | "peerDependenciesMeta": { 43 | "yup": { 44 | "optional": true 45 | }, 46 | "zod": { 47 | "optional": true 48 | } 49 | }, 50 | "devDependencies": { 51 | "@babel/preset-env": "^7.23.6", 52 | "@babel/preset-react": "^7.23.3", 53 | "@commitlint/cli": "^18.4.3", 54 | "@commitlint/config-conventional": "^18.4.3", 55 | "@rollup/plugin-babel": "^6.0.4", 56 | "@semantic-release/changelog": "^6.0.3", 57 | "@semantic-release/commit-analyzer": "^11.1.0", 58 | "@semantic-release/git": "^10.0.1", 59 | "@semantic-release/npm": "^11.0.2", 60 | "@semantic-release/release-notes-generator": "^12.1.0", 61 | "@testing-library/jest-dom": "^6.1.5", 62 | "@testing-library/react": "^14.1.2", 63 | "@types/jest": "^29.5.11", 64 | "@types/react": "^18.2.43", 65 | "@types/react-dom": "^18.2.17", 66 | "@types/yup": "^0.29.14", 67 | "@typescript-eslint/eslint-plugin": "^6.14.0", 68 | "@typescript-eslint/parser": "^6.14.0", 69 | "babel-jest": "^29.7.0", 70 | "coveralls": "^3.1.1", 71 | "eslint": "^8.55.0", 72 | "eslint-config-prettier": "^9.1.0", 73 | "eslint-plugin-react": "^7.33.2", 74 | "eslint-plugin-react-hooks": "^4.6.0", 75 | "husky": "^8.0.3", 76 | "jest": "^29.7.0", 77 | "jest-environment-jsdom": "^29.7.0", 78 | "npm-run-all": "^4.1.5", 79 | "onchange": "7.1.0", 80 | "prettier": "^3.1.1", 81 | "react": "^18.2.0", 82 | "react-dom": "^18.2.0", 83 | "react-test-renderer": "^18.2.0", 84 | "rimraf": "^5.0.5", 85 | "rollup": "^4.8.0", 86 | "rollup-plugin-dts": "^6.1.0", 87 | "rollup-plugin-typescript2": "^0.36.0", 88 | "ts-jest": "^29.1.1", 89 | "typescript": "^5.3.3", 90 | "yup": "^1.3.2", 91 | "zod": "^3.22.4" 92 | }, 93 | "keywords": [ 94 | "react", 95 | "form", 96 | "hook", 97 | "validation", 98 | "fluent" 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prepare: [ 3 | "@semantic-release/changelog", 4 | "@semantic-release/npm", 5 | { 6 | path: "@semantic-release/git", 7 | assets: ["package.json", "package-lock.json", "CHANGELOG.md"], 8 | message: "Release: ${nextRelease.version}\n\n${nextRelease.notes}", 9 | }, 10 | ], 11 | plugins: [ 12 | [ 13 | "@semantic-release/commit-analyzer", 14 | { 15 | preset: "conventionalcommits", 16 | releaseRules: [ 17 | { type: "feat", release: "minor" }, 18 | { type: "fix", release: "patch" }, 19 | { type: "perf", release: "patch" }, 20 | { type: "docs", scope: "readme", release: "patch" }, 21 | { type: "build", release: "patch" }, 22 | ], 23 | }, 24 | ], 25 | [ 26 | "@semantic-release/release-notes-generator", 27 | { 28 | preset: "conventionalcommits", 29 | presetConfig: { 30 | types: [ 31 | { type: "feat", section: "Features" }, 32 | { type: "fix", section: "Bug Fixes" }, 33 | { type: "test", section: "Tests", hidden: true }, 34 | { type: "refactor", section: "Code Refactoring", hidden: true }, 35 | { type: "perf", section: "Performance Improvements" }, 36 | { type: "docs", section: "Documentation" }, 37 | { type: "ci", section: "Continuous Integration", hidden: true }, 38 | { type: "chore", section: "Miscellaneous Chores", hidden: true }, 39 | { type: "build", section: "Build" }, 40 | ], 41 | }, 42 | }, 43 | ], 44 | "@semantic-release/npm", 45 | "@semantic-release/git", 46 | "@semantic-release/changelog", 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import dts from "rollup-plugin-dts"; 3 | import pkg from "./package.json" assert { type: "json" }; 4 | 5 | export default [ 6 | { 7 | input: "src/index.ts", 8 | output: [ 9 | { file: pkg.main, format: "cjs", exports: "named", sourcemap: true }, 10 | { file: pkg.module, format: "es", exports: "named", sourcemap: true }, 11 | ], 12 | external: Object.keys(pkg.devDependencies), 13 | plugins: [ 14 | typescript({ 15 | useTsconfigDeclarationDir: true, 16 | tsconfig: "tsconfig.package.json", 17 | tsconfigOverride: { 18 | compilerOptions: { 19 | declarationDir: "tmp/dts", 20 | }, 21 | }, 22 | }), 23 | ], 24 | }, 25 | { 26 | input: "tmp/dts/index.d.ts", 27 | output: { file: "dist/index.d.ts", format: "es" }, 28 | plugins: [dts()], 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/configuration/addField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "../fields/Field"; 2 | import { FieldCreator } from "../fields/FieldCreator"; 3 | 4 | export const addField = >( 5 | fieldName: string, 6 | fieldCreator: (...args: any[]) => F, 7 | ) => { 8 | (FieldCreator.prototype as any)[fieldName] = fieldCreator; 9 | }; 10 | -------------------------------------------------------------------------------- /src/constants/validationTrigger.ts: -------------------------------------------------------------------------------- 1 | export enum ValidationTrigger { 2 | AfterTouchOnChange = "AfterTouchOnChange", 3 | OnChange = "OnChange", 4 | OnSubmitOnly = "OnSubmitOnly", 5 | } 6 | -------------------------------------------------------------------------------- /src/fields/Field.ts: -------------------------------------------------------------------------------- 1 | import { ValidationTrigger } from "../constants/validationTrigger"; 2 | import { ComponentPropsMapper } from "../types"; 3 | 4 | export abstract class Field { 5 | public _initialValue: ValueType; 6 | 7 | public validationTrigger?: ValidationTrigger; 8 | 9 | constructor(initialValue: ValueType) { 10 | this._initialValue = initialValue; 11 | } 12 | 13 | public validateAfterTouchOnChange = () => { 14 | this.validationTrigger = ValidationTrigger.AfterTouchOnChange; 15 | return this; 16 | }; 17 | 18 | public validateOnChange = () => { 19 | this.validationTrigger = ValidationTrigger.OnChange; 20 | return this; 21 | }; 22 | 23 | public validateOnSubmitOnly = () => { 24 | this.validationTrigger = ValidationTrigger.OnSubmitOnly; 25 | return this; 26 | }; 27 | 28 | public abstract mapToComponentProps: ComponentPropsMapper< 29 | ValueType, 30 | ComponentProps 31 | >; 32 | } 33 | -------------------------------------------------------------------------------- /src/fields/FieldCreator.ts: -------------------------------------------------------------------------------- 1 | import { ValueInputTypes } from "../types"; 2 | import { CheckboxField } from "./input/CheckboxField"; 3 | import { RadioField } from "./input/RadioField"; 4 | import { TextField } from "./input/TextField"; 5 | import { RawField } from "./RawField"; 6 | import { SelectField } from "./SelectField"; 7 | import { TextAreaField } from "./TextAreaField"; 8 | 9 | export class FieldCreator { 10 | private inputWithValueType = (type: ValueInputTypes) => { 11 | return (initialValue?: string) => new TextField(initialValue).type(type); 12 | }; 13 | 14 | public checkbox = (initialChecked?: boolean) => 15 | new CheckboxField(initialChecked); 16 | public color = this.inputWithValueType("color"); 17 | public date = this.inputWithValueType("date"); 18 | public datetimeLocal = this.inputWithValueType("datetime-local"); 19 | public email = this.inputWithValueType("email"); 20 | public image = this.inputWithValueType("image"); 21 | public month = this.inputWithValueType("month"); 22 | public number = this.inputWithValueType("number"); 23 | public password = this.inputWithValueType("password"); 24 | public radio = (initialValue?: string) => new RadioField(initialValue); 25 | public range = this.inputWithValueType("range"); 26 | public search = this.inputWithValueType("search"); 27 | public tel = this.inputWithValueType("tel"); 28 | public text = this.inputWithValueType("text"); 29 | public time = this.inputWithValueType("time"); 30 | public url = this.inputWithValueType("url"); 31 | public week = this.inputWithValueType("week"); 32 | 33 | public textarea = (initialValue?: string) => new TextAreaField(initialValue); 34 | public select = (initialValue?: string) => new SelectField(initialValue); 35 | public raw = (initialValue: V) => new RawField(initialValue); 36 | } 37 | -------------------------------------------------------------------------------- /src/fields/FieldCreatorInstance.ts: -------------------------------------------------------------------------------- 1 | import { FieldCreator } from "./FieldCreator"; 2 | 3 | export const field = new FieldCreator(); 4 | -------------------------------------------------------------------------------- /src/fields/RawField.ts: -------------------------------------------------------------------------------- 1 | import { ComponentPropsMapper, RawProps, SetTouched } from "../types"; 2 | import { Field } from "./Field"; 3 | 4 | export class RawField< 5 | V, 6 | ValueName extends string = "value", 7 | OnChangeName extends string = "onChange", 8 | OnBlurName extends string = "onBlur", 9 | > extends Field> { 10 | protected valueProp = "value"; 11 | protected onChangeProp = "onChange"; 12 | protected onBlurProp = "onBlur"; 13 | 14 | constructor(initialValue: V) { 15 | super(initialValue); 16 | } 17 | 18 | protected handleBlur = (setTouched: SetTouched) => { 19 | return () => { 20 | setTouched(); 21 | }; 22 | }; 23 | 24 | public mapToComponentProps: ComponentPropsMapper< 25 | V, 26 | RawProps 27 | > = ({ value, setValue, setTouched }) => 28 | ({ 29 | [this.valueProp]: value, 30 | [this.onBlurProp]: this.handleBlur(setTouched), 31 | [this.onChangeProp]: setValue, 32 | }) as RawProps; 33 | 34 | public withValueProp = ( 35 | valueProp: NewValueName, 36 | ) => { 37 | this.valueProp = valueProp; 38 | return this as any as RawField; 39 | }; 40 | 41 | public withOnChangeProp = ( 42 | onChangeProp: NewOnChangeName, 43 | ) => { 44 | this.onChangeProp = onChangeProp; 45 | return this as any as RawField; 46 | }; 47 | 48 | public withOnBlurProp = ( 49 | onBlurProp: NewOnBlurName, 50 | ) => { 51 | this.onBlurProp = onBlurProp; 52 | return this as any as RawField; 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/fields/SelectField.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsMapper, 3 | SelectProps, 4 | SetTouched, 5 | SetValue, 6 | } from "../types"; 7 | import { Field } from "./Field"; 8 | 9 | export class SelectField extends Field { 10 | constructor(initialValue = "") { 11 | super(initialValue); 12 | } 13 | 14 | protected handleChange = (setValue: SetValue) => { 15 | return (e: React.ChangeEvent) => { 16 | setValue(e.target.value); 17 | }; 18 | }; 19 | 20 | protected handleBlur = (setTouched: SetTouched) => { 21 | return () => { 22 | setTouched(); 23 | }; 24 | }; 25 | 26 | public mapToComponentProps: ComponentPropsMapper = ({ 27 | value, 28 | setValue, 29 | setTouched, 30 | }) => ({ 31 | select: { 32 | value, 33 | onBlur: this.handleBlur(setTouched), 34 | onChange: this.handleChange(setValue), 35 | }, 36 | option: (value: string) => ({ 37 | value, 38 | }), 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/fields/TextAreaField.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsMapper, 3 | SetTouched, 4 | SetValue, 5 | TextAreaProps, 6 | } from "../types"; 7 | import { Field } from "./Field"; 8 | 9 | export class TextAreaField extends Field { 10 | constructor(initialValue = "") { 11 | super(initialValue); 12 | } 13 | 14 | protected handleChange = (setValue: SetValue) => { 15 | return (e: React.ChangeEvent) => { 16 | setValue(e.target.value); 17 | }; 18 | }; 19 | 20 | protected handleBlur = (setTouched: SetTouched) => { 21 | return () => { 22 | setTouched(); 23 | }; 24 | }; 25 | 26 | public mapToComponentProps: ComponentPropsMapper = ({ 27 | value, 28 | setValue, 29 | setTouched, 30 | }) => ({ 31 | value, 32 | onBlur: this.handleBlur(setTouched), 33 | onChange: this.handleChange(setValue), 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/fields/input/CheckboxField.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CheckboxInputType, 3 | ComponentPropsMapper, 4 | InputPropsChecked, 5 | SetValue, 6 | } from "../../types"; 7 | import { InputField } from "./InputField"; 8 | 9 | export class CheckboxField extends InputField { 10 | constructor(initialValue = false) { 11 | // workarround for coverage bug: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-544618903 12 | super(initialValue) /* istanbul ignore next */; 13 | this._type = "checkbox"; 14 | } 15 | 16 | public type = (value: CheckboxInputType) => { 17 | return super.type(value); 18 | }; 19 | 20 | protected handleChange = (setValue: SetValue) => { 21 | return (e: React.ChangeEvent) => { 22 | setValue(e.target.checked); 23 | }; 24 | }; 25 | 26 | public mapToComponentProps: ComponentPropsMapper = 27 | ({ value: checked, setValue, setTouched }) => { 28 | return { 29 | type: this._type, 30 | checked, 31 | onBlur: this.handleBlur(setTouched), 32 | onChange: this.handleChange(setValue), 33 | }; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/fields/input/InputField.ts: -------------------------------------------------------------------------------- 1 | import { Input, SetTouched } from "../../types"; 2 | import { Field } from "../Field"; 3 | 4 | export abstract class InputField extends Field { 5 | protected _type?: Input; 6 | 7 | public type(value?: Input) { 8 | this._type = value; 9 | return this; 10 | } 11 | 12 | protected handleBlur = (setTouched: SetTouched) => { 13 | return () => { 14 | setTouched(); 15 | }; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/fields/input/RadioField.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsMapper, 3 | InputPropsRadioGenerator, 4 | RadioInputType, 5 | SetValue, 6 | } from "../../types"; 7 | import { InputField } from "./InputField"; 8 | 9 | export class RadioField extends InputField { 10 | private _name?: string; 11 | private _unselectable?: boolean; 12 | 13 | constructor(initialValue = "") { 14 | // workarround for coverage bug: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-544618903 15 | super(initialValue) /* istanbul ignore next */; 16 | this._type = "radio"; 17 | } 18 | 19 | public name = (value: string) => { 20 | this._name = value; 21 | return this; 22 | }; 23 | 24 | public unselectable = (value = true) => { 25 | this._unselectable = value; 26 | return this; 27 | }; 28 | 29 | public type = (value: RadioInputType) => { 30 | return super.type(value); 31 | }; 32 | 33 | protected handleClick = (setValue: SetValue, value: string) => { 34 | return (e: React.MouseEvent) => { 35 | const newValue = e.currentTarget.value; 36 | if (this._unselectable && newValue === value) { 37 | setValue(""); 38 | } 39 | }; 40 | }; 41 | 42 | protected handleChange = (setValue: SetValue) => { 43 | return (e: React.ChangeEvent) => { 44 | setValue(e.target.value); 45 | }; 46 | }; 47 | 48 | public mapToComponentProps: ComponentPropsMapper< 49 | string, 50 | InputPropsRadioGenerator 51 | > = 52 | ({ value, setValue, setTouched }) => 53 | (radioInputValue: string) => ({ 54 | type: this._type, 55 | checked: radioInputValue === value, 56 | name: this._name, 57 | value: radioInputValue, 58 | onBlur: this.handleBlur(setTouched), 59 | onChange: this.handleChange(setValue), 60 | onClick: this.handleClick(setValue, value), 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/fields/input/TextField.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsMapper, 3 | InputPropsValue, 4 | SetValue, 5 | ValueInputTypes, 6 | } from "../../types"; 7 | import { InputField } from "./InputField"; 8 | 9 | export class TextField extends InputField { 10 | constructor(initialValue = "") { 11 | // workarround for coverage bug: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-544618903 12 | super(initialValue) /* istanbul ignore next */; 13 | } 14 | 15 | public type = (value: ValueInputTypes) => { 16 | return super.type(value); 17 | }; 18 | 19 | protected handleChange = (setValue: SetValue) => { 20 | return (e: React.ChangeEvent) => { 21 | setValue(e.target.value); 22 | }; 23 | }; 24 | 25 | public mapToComponentProps: ComponentPropsMapper = ({ 26 | value, 27 | setValue, 28 | setTouched, 29 | }) => ({ 30 | type: this._type, 31 | value, 32 | onBlur: this.handleBlur(setTouched), 33 | onChange: this.handleChange(setValue), 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/form-config/FormArrayConfig.ts: -------------------------------------------------------------------------------- 1 | import { ErrorsType, Fields, KeyGenerator } from "../types"; 2 | import { FormConfig } from "./FormConfig"; 3 | 4 | export class FormArrayConfig< 5 | ValuesType extends object = any, 6 | F extends Fields = Fields, 7 | E extends ErrorsType = any, 8 | > extends FormConfig { 9 | public _initialArray: ValuesType[] = []; 10 | public _generateKey?: KeyGenerator; 11 | 12 | public withInitialArray = (initialArray: ValuesType[]) => { 13 | this._initialArray = initialArray; 14 | return this; 15 | }; 16 | 17 | public withKeyGenerator = (keyGenerator: KeyGenerator) => { 18 | this._generateKey = keyGenerator; 19 | return this; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/form-config/FormArrayConfigHelper.ts: -------------------------------------------------------------------------------- 1 | import { ExtractErrorsType, ExtractValuesType, FormArrayState } from "../types"; 2 | import { FormArrayConfig } from "./FormArrayConfig"; 3 | import { FormConfigHelper } from "./FormConfigHelper"; 4 | 5 | export class FormArrayConfigHelper { 6 | private formArrayConfig: Config; 7 | private formConfigHelper: FormConfigHelper; 8 | 9 | constructor(formArrayConfig: Config) { 10 | this.formArrayConfig = formArrayConfig; 11 | this.formConfigHelper = new FormConfigHelper(formArrayConfig); 12 | } 13 | 14 | public getInitialArrayValues = ( 15 | initialArray: ExtractValuesType[] = this.formArrayConfig 16 | ._initialArray, 17 | ): FormArrayState, ExtractErrorsType> => { 18 | type Errors = ExtractErrorsType; 19 | 20 | const { _generateKey } = this.formArrayConfig; 21 | 22 | const formArray: FormArrayState, Errors> = {}; 23 | 24 | for (let i = 0; i < initialArray.length; i++) { 25 | const item = initialArray[i]; 26 | const key = (_generateKey && _generateKey(item)) || i; 27 | formArray[key] = { 28 | key: key, 29 | sortPosition: i, 30 | ...this.formConfigHelper.getInitialState(item), 31 | }; 32 | } 33 | 34 | return formArray; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/form-config/FormConfig.ts: -------------------------------------------------------------------------------- 1 | import { ValidationTrigger } from "../constants/validationTrigger"; 2 | import { DefaultError, ErrorsType, Fields, Validations } from "../types"; 3 | import { DefaultValidator } from "../validation/DefaultValidator"; 4 | import { Validator } from "../validation/Validator"; 5 | 6 | export class FormConfig< 7 | ValuesType extends object = any, 8 | F extends Fields = Fields, 9 | E extends ErrorsType = any, 10 | > { 11 | public _fields: F; 12 | public _initialValues: Partial = {}; 13 | public _validator?: Validator; 14 | public _context?: object; 15 | public _validationTrigger: ValidationTrigger = 16 | ValidationTrigger.AfterTouchOnChange; 17 | public _validateOnContextChange?: boolean; 18 | 19 | constructor(fields: F) { 20 | this._fields = fields; 21 | } 22 | 23 | public validateAfterTouchOnChange = () => { 24 | this._validationTrigger = ValidationTrigger.AfterTouchOnChange; 25 | return this; 26 | }; 27 | 28 | public validateOnChange = () => { 29 | this._validationTrigger = ValidationTrigger.OnChange; 30 | return this; 31 | }; 32 | 33 | public validateOnSubmitOnly = () => { 34 | this._validationTrigger = ValidationTrigger.OnSubmitOnly; 35 | return this; 36 | }; 37 | 38 | public validateOnContextChange = (validate = true) => { 39 | this._validateOnContextChange = validate; 40 | return this; 41 | }; 42 | 43 | public withInitialValues(values: Partial) { 44 | this._initialValues = values; 45 | return this; 46 | } 47 | 48 | public withValidation>( 49 | validations: V, 50 | ): this & FormConfig> { 51 | this._validator = new DefaultValidator(validations) as any; 52 | return this; 53 | } 54 | 55 | public withCustomValidator>( 56 | validator: V, 57 | ): this & FormConfig> { 58 | this._validator = validator as any; 59 | // TODO improve return type 60 | return this as any; 61 | } 62 | 63 | public withContext(context: object) { 64 | this._context = context; 65 | return this; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/form-config/FormConfigHelper.ts: -------------------------------------------------------------------------------- 1 | import { ValidationTrigger } from "../constants/validationTrigger"; 2 | import { 3 | ExtractErrorsType, 4 | ExtractFieldsType, 5 | ExtractValuesType, 6 | FluentFormState, 7 | MappedFields, 8 | } from "../types"; 9 | import { FormConfig } from "./FormConfig"; 10 | 11 | export class FormConfigHelper { 12 | protected formConfig: Config; 13 | 14 | constructor(formConfig: Config) { 15 | this.formConfig = formConfig; 16 | } 17 | 18 | public getInitialState( 19 | initialValues: Partial> = {}, 20 | ): FluentFormState, ExtractErrorsType> { 21 | return { 22 | values: { ...this.getInitialValues(), ...initialValues }, 23 | touched: {}, 24 | validity: {}, 25 | errors: {} as ExtractErrorsType, 26 | context: this.formConfig._context || {}, 27 | submitting: false, 28 | }; 29 | } 30 | 31 | public getInitialValues(): ExtractValuesType { 32 | const valuesFromFields = this.getInitialValuesFromFields(); 33 | return { ...valuesFromFields, ...this.formConfig._initialValues }; 34 | } 35 | 36 | public getValidationResultForAllFields( 37 | values: ExtractValuesType, 38 | context: object, 39 | ): ExtractErrorsType { 40 | if (this.formConfig._validator) { 41 | return this.formConfig._validator.validateAllFields(values, context); 42 | } else { 43 | return {} as ExtractErrorsType; 44 | } 45 | } 46 | 47 | public getValidationResultForField>( 48 | field: K, 49 | value: ExtractValuesType[K], 50 | values: ExtractValuesType, 51 | context: object, 52 | ): ExtractErrorsType[K] { 53 | if (this.formConfig._validator) { 54 | const error = this.formConfig._validator.validateField( 55 | field, 56 | { 57 | ...values, 58 | [field]: value, 59 | }, 60 | context, 61 | ); 62 | 63 | return error; 64 | } else { 65 | return undefined; 66 | } 67 | } 68 | 69 | public getMappedFields( 70 | values: ExtractValuesType, 71 | onTouch: >( 72 | field: K, 73 | value: boolean, 74 | ) => void, 75 | onChange: >( 76 | field: K, 77 | value: ExtractValuesType[K], 78 | ) => void, 79 | ) { 80 | type Fields = ExtractFieldsType; 81 | type Values = ExtractValuesType; 82 | 83 | const fields = this.formConfig._fields; 84 | 85 | return (Object.keys(fields) as Array).reduce( 86 | ( 87 | mappedObj: MappedFields, 88 | key: K, 89 | ): MappedFields => { 90 | const setValueForField = (value: Values[K]) => { 91 | onChange(key, value); 92 | }; 93 | 94 | // eslint-disable-next-line @typescript-eslint/no-inferrable-types 95 | const setTouchedForField = (value: boolean = true) => { 96 | onTouch(key, value); 97 | }; 98 | return { 99 | ...mappedObj, 100 | [key]: this.formConfig._fields[key].mapToComponentProps({ 101 | value: values[key], 102 | setValue: setValueForField, 103 | setTouched: setTouchedForField, 104 | }), 105 | }; 106 | }, 107 | {} as MappedFields, 108 | ); 109 | } 110 | 111 | public shouldValidateOnChange>( 112 | field: K, 113 | touched?: boolean, 114 | ) { 115 | const toConsiderTrigger = this.getToConsiderTrigger(field); 116 | 117 | return ( 118 | toConsiderTrigger === ValidationTrigger.OnChange || 119 | (toConsiderTrigger === ValidationTrigger.AfterTouchOnChange && !!touched) 120 | ); 121 | } 122 | 123 | public shouldValidateOnBlur>( 124 | field: K, 125 | touchedNow: boolean, 126 | ) { 127 | const toConsiderTrigger = this.getToConsiderTrigger(field); 128 | 129 | return ( 130 | toConsiderTrigger === ValidationTrigger.AfterTouchOnChange && touchedNow 131 | ); 132 | } 133 | 134 | private getToConsiderTrigger>( 135 | field: K, 136 | ) { 137 | const fieldTrigger = this.getFieldTrigger(field); 138 | const globalTrigger = this.formConfig._validationTrigger; 139 | 140 | return fieldTrigger || globalTrigger; 141 | } 142 | 143 | private getFieldTrigger>(field: K) { 144 | return this.formConfig._fields[field].validationTrigger; 145 | } 146 | 147 | private getInitialValuesFromFields(): ExtractValuesType { 148 | const keys = Object.keys(this.formConfig._fields) as Array< 149 | keyof ExtractValuesType 150 | >; 151 | 152 | return keys.reduce((initialValues, key) => { 153 | return { 154 | ...initialValues, 155 | [key]: this.formConfig._fields[key]._initialValue, 156 | }; 157 | }, {} as ExtractValuesType); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/form-config/FormCreators.ts: -------------------------------------------------------------------------------- 1 | import { Fields } from "../types"; 2 | import { FormArrayConfig } from "./FormArrayConfig"; 3 | import { FormConfig } from "./FormConfig"; 4 | 5 | export const createForm = () => { 6 | return >(fields: F) => 7 | new FormConfig(fields); 8 | }; 9 | 10 | export const createFormArray = () => { 11 | return >(fields: F) => 12 | new FormArrayConfig(fields); 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/fluent-form-array/state-manager/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | ErrorsType, 4 | FluentFormArrayState, 5 | FluentFormState, 6 | FormArrayError, 7 | FormItem, 8 | FormKey, 9 | } from "../../../types"; 10 | import { 11 | deriveValidityFromErrors, 12 | generateAllTouchedTrue, 13 | } from "../../../utils/stateUtils"; 14 | 15 | export type FluentFormArrayActionTypes< 16 | ValuesType extends object, 17 | K extends keyof ValuesType, 18 | E extends ErrorsType, 19 | > = 20 | | Action< 21 | "ADD_FORM", 22 | { 23 | key: FormKey; 24 | sortPosition: number; 25 | initialState: FluentFormState; 26 | } 27 | > 28 | | Action<"REMOVE_FORM", { key: FormKey }> 29 | | Action<"SUBMITTING_ARRAY"> 30 | | Action<"FINISHED_SUBMITTING_ARRAY", { formArrayErrors: FormArrayError }> 31 | | Action<"RESET_ARRAY", FluentFormArrayState> 32 | | Action<"SET_VALUES", { key: FormKey; values: Partial }> 33 | | Action< 34 | "SET_SINGLE_VALUE", 35 | { key: FormKey; field: K; value: ValuesType[K]; touched?: boolean } 36 | > 37 | | Action<"SET_SINGLE_TOUCHED", { key: FormKey; field: K; touched?: boolean }> 38 | | Action<"SET_CONTEXT", { key: FormKey; context: object }> 39 | | Action< 40 | "VALUE_CHANGE", 41 | { key: FormKey; field: K; value: ValuesType[K]; touched?: boolean } 42 | > 43 | | Action<"VALIDATION_FAILURE", { key: FormKey; field: K; error: E[K] }> 44 | | Action<"VALIDATION_SUCCESS", { key: FormKey; field: K }> 45 | | Action<"SUBMITTING", { key: FormKey }> 46 | | Action<"FINISHED_SUBMITTING", { key: FormKey; errors: E }> 47 | | Action<"RESET", FormItem>; 48 | 49 | export const fluentFormArrayReducer = < 50 | ValuesType extends object, 51 | K extends keyof ValuesType, 52 | E extends ErrorsType, 53 | >( 54 | state: FluentFormArrayState, 55 | action: FluentFormArrayActionTypes, 56 | ): FluentFormArrayState => { 57 | switch (action.type) { 58 | case "ADD_FORM": { 59 | const { key, sortPosition, initialState } = action.payload; 60 | 61 | return { 62 | ...state, 63 | formArray: { 64 | ...state.formArray, 65 | [key]: { 66 | key, 67 | sortPosition, 68 | ...initialState, 69 | }, 70 | }, 71 | }; 72 | } 73 | case "REMOVE_FORM": { 74 | const { key } = action.payload; 75 | 76 | const stateCopy: FluentFormArrayState = { 77 | ...state, 78 | formArray: { 79 | ...state.formArray, 80 | }, 81 | }; 82 | 83 | delete stateCopy.formArray[key]; 84 | 85 | return stateCopy; 86 | } 87 | case "SUBMITTING_ARRAY": { 88 | return { 89 | ...state, 90 | submitting: true, 91 | }; 92 | } 93 | case "FINISHED_SUBMITTING_ARRAY": { 94 | const { formArrayErrors } = action.payload; 95 | const keys = Object.keys(formArrayErrors); 96 | 97 | const stateCopy: FluentFormArrayState = { 98 | ...state, 99 | formArray: { 100 | ...state.formArray, 101 | }, 102 | submitting: false, 103 | }; 104 | 105 | for (const key of keys) { 106 | const formItem = stateCopy.formArray[key]; 107 | const errors = formArrayErrors[key]; 108 | const fields = Object.keys(formItem.values) as Array; 109 | 110 | const touched = generateAllTouchedTrue(fields); 111 | const validity = deriveValidityFromErrors(fields, errors); 112 | 113 | stateCopy.formArray[key] = { 114 | ...stateCopy.formArray[key], 115 | touched, 116 | validity, 117 | errors, 118 | submitting: false, 119 | }; 120 | } 121 | 122 | return stateCopy; 123 | } 124 | case "RESET_ARRAY": { 125 | return { ...action.payload }; 126 | } 127 | case "SET_VALUES": { 128 | const { key, values } = action.payload; 129 | const form = state.formArray[key]; 130 | 131 | return { 132 | ...state, 133 | formArray: { 134 | ...state.formArray, 135 | [key]: { 136 | ...form, 137 | values: { 138 | ...form.values, 139 | ...values, 140 | }, 141 | }, 142 | }, 143 | }; 144 | } 145 | case "SET_SINGLE_TOUCHED": { 146 | const { key, field, touched } = action.payload; 147 | const formItem = state.formArray[key]; 148 | 149 | return { 150 | ...state, 151 | formArray: { 152 | ...state.formArray, 153 | [key]: { 154 | ...formItem, 155 | touched: { ...formItem.touched, [field]: touched }, 156 | }, 157 | }, 158 | }; 159 | } 160 | case "SET_CONTEXT": { 161 | const { key, context } = action.payload; 162 | const form = state.formArray[key]; 163 | 164 | return { 165 | ...state, 166 | formArray: { 167 | ...state.formArray, 168 | [key]: { 169 | ...form, 170 | context, 171 | }, 172 | }, 173 | }; 174 | } 175 | case "VALUE_CHANGE": { 176 | const { key, field, value, touched } = action.payload; 177 | const form = state.formArray[key]; 178 | return { 179 | ...state, 180 | formArray: { 181 | ...state.formArray, 182 | [key]: { 183 | ...form, 184 | values: { ...form.values, [field]: value }, 185 | touched: { ...form.touched, [field]: touched }, 186 | }, 187 | }, 188 | }; 189 | } 190 | case "VALIDATION_FAILURE": { 191 | const { key, field, error } = action.payload; 192 | const form = state.formArray[key]; 193 | return { 194 | ...state, 195 | formArray: { 196 | ...state.formArray, 197 | [key]: { 198 | ...form, 199 | errors: { ...form.errors, [field]: error }, 200 | validity: { ...form.validity, [field]: false }, 201 | }, 202 | }, 203 | }; 204 | } 205 | 206 | case "VALIDATION_SUCCESS": { 207 | const { key, field } = action.payload; 208 | const form = state.formArray[key]; 209 | return { 210 | ...state, 211 | formArray: { 212 | ...state.formArray, 213 | [key]: { 214 | ...form, 215 | errors: { ...form.errors, [field]: undefined }, 216 | validity: { ...form.validity, [field]: true }, 217 | }, 218 | }, 219 | }; 220 | } 221 | case "SUBMITTING": { 222 | const { key } = action.payload; 223 | const form = state.formArray[key]; 224 | return { 225 | ...state, 226 | formArray: { 227 | ...state.formArray, 228 | [key]: { 229 | ...form, 230 | submitting: true, 231 | }, 232 | }, 233 | }; 234 | } 235 | case "FINISHED_SUBMITTING": { 236 | const { key, errors } = action.payload; 237 | const form = state.formArray[key]; 238 | const fields = Object.keys(form.values) as Array; 239 | 240 | const touched = generateAllTouchedTrue(fields); 241 | const validity = deriveValidityFromErrors(fields, errors); 242 | 243 | return { 244 | ...state, 245 | formArray: { 246 | ...state.formArray, 247 | [key]: { 248 | ...form, 249 | touched, 250 | validity, 251 | errors, 252 | submitting: false, 253 | }, 254 | }, 255 | }; 256 | } 257 | case "RESET": { 258 | const { key } = action.payload; 259 | 260 | return { 261 | ...state, 262 | formArray: { 263 | ...state.formArray, 264 | [key]: { 265 | ...action.payload, 266 | }, 267 | }, 268 | }; 269 | } 270 | default: 271 | return state; 272 | } 273 | }; 274 | -------------------------------------------------------------------------------- /src/hooks/fluent-form-array/state-manager/useFluentArrayStateManager.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; 2 | 3 | import { FormArrayConfig } from "../../../form-config/FormArrayConfig"; 4 | import { FormArrayConfigHelper } from "../../../form-config/FormArrayConfigHelper"; 5 | import { FormConfigHelper } from "../../../form-config/FormConfigHelper"; 6 | import { 7 | AddFormArgs, 8 | ExtractErrorsType, 9 | ExtractFieldsType, 10 | ExtractValuesType, 11 | FluentFormArrayReducer, 12 | FluentFormArrayState, 13 | FluentFormInitialStates, 14 | FluentFormState, 15 | FormArrayError, 16 | FormKey, 17 | UseFluentArrayStateManager, 18 | } from "../../../types"; 19 | import { fluentFormArrayReducer } from "./reducer"; 20 | 21 | export function useFluentArrayStateManager( 22 | config: Config, 23 | ): UseFluentArrayStateManager { 24 | type Values = ExtractValuesType; 25 | type Fields = ExtractFieldsType; 26 | type Errors = ExtractErrorsType; 27 | 28 | const formArrayConfigHelperRef = useRef(new FormArrayConfigHelper(config)); 29 | const { current: formArrayConfigHelper } = formArrayConfigHelperRef; 30 | 31 | const initalFormArrayValues = useMemo( 32 | () => formArrayConfigHelper.getInitialArrayValues(), 33 | // eslint-disable-next-line react-hooks/exhaustive-deps 34 | [], 35 | ); 36 | 37 | const initialArrayStateRef = useRef>({ 38 | submitting: false, 39 | formArray: initalFormArrayValues, 40 | }); 41 | 42 | const [state, dispatch] = useReducer>( 43 | fluentFormArrayReducer, 44 | initialArrayStateRef.current, 45 | ); 46 | 47 | const sortPositionCountRef = useRef(Object.keys(state.formArray).length); 48 | 49 | const formConfigHelperRef = useRef(new FormConfigHelper(config)); 50 | const { current: formConfigHelper } = formConfigHelperRef; 51 | 52 | const { _generateKey } = config; 53 | 54 | const initalStateRefs = useRef>({}); 55 | 56 | const uniqueKeyCounter = useRef(0); 57 | 58 | const getUniqueKey = useCallback(() => { 59 | while (true) { 60 | if (!state.formArray.hasOwnProperty(uniqueKeyCounter.current)) { 61 | return uniqueKeyCounter.current++; 62 | } 63 | uniqueKeyCounter.current++; 64 | } 65 | }, [state.formArray]); 66 | 67 | const setInitialStateRefsFromInitialArray = useCallback(() => { 68 | const entries = Object.entries(initialArrayStateRef.current.formArray); 69 | if (!entries.length) return; 70 | 71 | initalStateRefs.current = {}; 72 | for (let i = 0; i < entries.length; i++) { 73 | const [key, item] = entries[i]; 74 | initalStateRefs.current[key] = 75 | formConfigHelperRef.current.getInitialState(item.values); 76 | } 77 | }, []); 78 | 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | useEffect(setInitialStateRefsFromInitialArray, []); 81 | 82 | const setInitialArrayRef = useCallback( 83 | (initialArray: Values[]) => { 84 | initialArrayStateRef.current.formArray = 85 | formArrayConfigHelper.getInitialArrayValues(initialArray); 86 | }, 87 | [formArrayConfigHelper], 88 | ); 89 | 90 | const addForm = useCallback( 91 | ({ initialValues, key }: AddFormArgs = {}) => { 92 | const initialState: FluentFormState = 93 | formConfigHelper.getInitialState(initialValues); 94 | 95 | key = 96 | key || 97 | (initialValues && _generateKey && _generateKey(initialValues)) || 98 | getUniqueKey(); 99 | 100 | initalStateRefs.current[key] = initialState; 101 | 102 | dispatch({ 103 | type: "ADD_FORM", 104 | payload: { 105 | key, 106 | sortPosition: sortPositionCountRef.current++, 107 | initialState, 108 | }, 109 | }); 110 | }, 111 | [_generateKey, formConfigHelper, getUniqueKey], 112 | ); 113 | 114 | const removeForm = useCallback((key: FormKey) => { 115 | delete initalStateRefs.current[key]; 116 | dispatch({ 117 | type: "REMOVE_FORM", 118 | payload: { key }, 119 | }); 120 | }, []); 121 | 122 | const startSubmittingArray = useCallback(() => { 123 | dispatch({ type: "SUBMITTING_ARRAY" }); 124 | }, []); 125 | 126 | const setSubmittingResultForArray = useCallback( 127 | (formArrayErrors: FormArrayError) => { 128 | dispatch({ 129 | type: "FINISHED_SUBMITTING_ARRAY", 130 | payload: { formArrayErrors }, 131 | }); 132 | }, 133 | [], 134 | ); 135 | 136 | const resetArray = useCallback(() => { 137 | dispatch({ type: "RESET_ARRAY", payload: initialArrayStateRef.current }); 138 | setInitialStateRefsFromInitialArray(); 139 | }, [setInitialStateRefsFromInitialArray]); 140 | 141 | const setContext = useCallback((key: FormKey, context: object) => { 142 | dispatch({ type: "SET_CONTEXT", payload: { key, context } }); 143 | }, []); 144 | 145 | const setInitialValuesRef = useCallback( 146 | (key: FormKey, values: Partial) => { 147 | initalStateRefs.current[key].values = { 148 | ...initalStateRefs.current[key].values, 149 | ...values, 150 | }; 151 | }, 152 | [], 153 | ); 154 | 155 | const setSubmittingResult = useCallback((key: FormKey, errors: Errors) => { 156 | dispatch({ 157 | type: "FINISHED_SUBMITTING", 158 | payload: { key, errors: errors }, 159 | }); 160 | }, []); 161 | 162 | const setTouched = useCallback( 163 | (key: FormKey, field: K, touched: boolean) => { 164 | dispatch({ 165 | type: "SET_SINGLE_TOUCHED", 166 | payload: { key, field, touched }, 167 | }); 168 | }, 169 | [], 170 | ); 171 | 172 | const setValidationFailure = useCallback( 173 | (key: FormKey, field: K, error: Errors[K]) => { 174 | dispatch({ 175 | type: "VALIDATION_FAILURE", 176 | payload: { key, field, error: error }, 177 | }); 178 | }, 179 | [], 180 | ); 181 | 182 | const setValidationSuccess = useCallback( 183 | (key: FormKey, field: K) => { 184 | dispatch({ 185 | type: "VALIDATION_SUCCESS", 186 | payload: { key, field }, 187 | }); 188 | }, 189 | [], 190 | ); 191 | 192 | const setValue = useCallback( 193 | ( 194 | key: FormKey, 195 | field: K, 196 | value: Values[K], 197 | touched?: boolean, 198 | ) => { 199 | dispatch({ 200 | type: "VALUE_CHANGE", 201 | payload: { key, field, value, touched }, 202 | }); 203 | }, 204 | [], 205 | ); 206 | 207 | const setValues = useCallback((key: FormKey, values: Partial) => { 208 | dispatch({ type: "SET_VALUES", payload: { key, values } }); 209 | }, []); 210 | 211 | const startSubmitting = useCallback((key: FormKey) => { 212 | dispatch({ type: "SUBMITTING", payload: { key } }); 213 | }, []); 214 | 215 | const reset = useCallback( 216 | (key: FormKey) => { 217 | dispatch({ 218 | type: "RESET", 219 | payload: { ...state.formArray[key], ...initalStateRefs.current[key] }, 220 | }); 221 | }, 222 | [state.formArray], 223 | ); 224 | 225 | const formsAsArrays = useMemo( 226 | () => 227 | Object.values(state.formArray).sort( 228 | (a, b) => a.sortPosition - b.sortPosition, 229 | ), 230 | [state.formArray], 231 | ); 232 | 233 | return { 234 | submitting: state.submitting, 235 | formArray: formsAsArrays, 236 | formConfigHelperRef, 237 | formArrayConfigHelperRef, 238 | initalStateRefs, 239 | setInitialArrayRef, 240 | startSubmittingArray, 241 | setSubmittingResultForArray, 242 | resetArray, 243 | setContext, 244 | setInitialValuesRef, 245 | setSubmittingResult, 246 | setTouched, 247 | setValidationFailure, 248 | setValidationSuccess, 249 | setValue, 250 | setValues, 251 | startSubmitting, 252 | reset, 253 | addForm, 254 | removeForm, 255 | }; 256 | } 257 | -------------------------------------------------------------------------------- /src/hooks/fluent-form-array/useFluentFormArray.ts: -------------------------------------------------------------------------------- 1 | import { FormArrayConfig } from "../../form-config/FormArrayConfig"; 2 | import { UseFluentFormArray } from "../../types"; 3 | import { useFluentArrayStateManager } from "./state-manager/useFluentArrayStateManager"; 4 | import { useFluentFormArrayBase } from "./useFluentFormArrayBase"; 5 | 6 | export function useFluentFormArray( 7 | config: Config, 8 | ): UseFluentFormArray { 9 | const stateManager = useFluentArrayStateManager(config); 10 | 11 | return useFluentFormArrayBase(config, stateManager); 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/fluent-form-array/useFluentFormArrayBase.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | import { FormArrayConfig } from "../../form-config/FormArrayConfig"; 4 | import { 5 | ExtractErrorsType, 6 | FormArrayError, 7 | FormKey, 8 | UseFluentArrayStateManager, 9 | UseFluentFormArray, 10 | } from "../../types"; 11 | import { useHandleSubmit } from "../helper/useHandleSubmit"; 12 | 13 | export function useFluentFormArrayBase( 14 | config: Config, 15 | stateManager: UseFluentArrayStateManager, 16 | ): UseFluentFormArray { 17 | type Errors = ExtractErrorsType; 18 | 19 | const { 20 | formArray, 21 | submitting, 22 | startSubmittingArray, 23 | formConfigHelperRef, 24 | setSubmittingResultForArray, 25 | } = stateManager; 26 | 27 | const validateAllForms = useCallback(() => { 28 | const formArrayErrors: FormArrayError = {}; 29 | 30 | for (const formItem of formArray) { 31 | formArrayErrors[formItem.key] = 32 | formConfigHelperRef.current.getValidationResultForAllFields( 33 | formItem.values, 34 | formItem.context, 35 | ); 36 | } 37 | setSubmittingResultForArray(formArrayErrors); 38 | }, [formConfigHelperRef, formArray, setSubmittingResultForArray]); 39 | 40 | const valid = useMemo( 41 | () => 42 | formArray.every((form) => 43 | Object.values(form.validity).every((validityValue) => validityValue), 44 | ), 45 | [formArray], 46 | ); 47 | 48 | const handleSubmit = useHandleSubmit({ 49 | valid, 50 | submitting, 51 | startSubmitting: startSubmittingArray, 52 | submitAction: validateAllForms, 53 | }); 54 | 55 | const mappedFormArray = useMemo( 56 | () => 57 | stateManager.formArray.map((form) => ({ 58 | key: form.key, 59 | stateManager, 60 | config, 61 | })), 62 | [stateManager, config], 63 | ); 64 | 65 | const getFormStateByKey = useCallback( 66 | (key: FormKey) => formArray.find((formItem) => formItem.key === key), 67 | [formArray], 68 | ); 69 | 70 | return { 71 | formArray: mappedFormArray, 72 | formStates: formArray, 73 | submitting, 74 | setInitialArray: stateManager.setInitialArrayRef, 75 | addForm: stateManager.addForm, 76 | removeForm: stateManager.removeForm, 77 | resetArray: stateManager.resetArray, 78 | getFormStateByKey, 79 | handleSubmit, 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/fluent-form-array/useFluentFormItem.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | import { FormArrayConfig } from "../../form-config/FormArrayConfig"; 4 | import { UseFluentFormItem, UseFluentFormItemArgs } from "../../types"; 5 | import { useFluentFormBase } from "../fluent-form/useFluentFormBase"; 6 | import { useStateManagerMapper } from "../helper/useStateManagerMapper"; 7 | 8 | export function useFluentFormItem( 9 | args: UseFluentFormItemArgs, 10 | ): UseFluentFormItem { 11 | const { stateManager, key, config } = args; 12 | const { removeForm } = stateManager; 13 | 14 | const fluentStateManager = useStateManagerMapper(key, stateManager); 15 | const fluentFormBase = useFluentFormBase(config, fluentStateManager); 16 | 17 | const removeSelf = useCallback(() => { 18 | removeForm(key); 19 | }, [key, removeForm]); 20 | 21 | return { ...fluentFormBase, key, removeSelf }; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/fluent-form/state-manager/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, ErrorsType, FluentFormState } from "../../../types"; 2 | import { 3 | deriveValidityFromErrors, 4 | generateAllTouchedTrue, 5 | } from "../../../utils/stateUtils"; 6 | 7 | export type FluentFormActionTypes< 8 | ValuesType extends object, 9 | K extends keyof ValuesType, 10 | E extends ErrorsType, 11 | > = 12 | | Action<"SET_VALUES", { values: Partial }> 13 | | Action< 14 | "SET_SINGLE_VALUE", 15 | { field: K; value: ValuesType[K]; touched?: boolean } 16 | > 17 | | Action<"SET_SINGLE_TOUCHED", { field: K; touched?: boolean }> 18 | | Action<"SET_CONTEXT", { context: object }> 19 | | Action< 20 | "VALUE_CHANGE", 21 | { field: K; value: ValuesType[K]; touched?: boolean } 22 | > 23 | | Action<"VALIDATION_FAILURE", { field: K; error: E[K] }> 24 | | Action<"VALIDATION_SUCCESS", { field: K }> 25 | | Action<"SUBMITTING"> 26 | | Action<"FINISHED_SUBMITTING", { errors: E }> 27 | | Action<"RESET", FluentFormState>; 28 | 29 | export const fluentFormReducer = < 30 | ValuesType extends object, 31 | K extends keyof ValuesType, 32 | E extends ErrorsType, 33 | >( 34 | state: FluentFormState, 35 | action: FluentFormActionTypes, 36 | ): FluentFormState => { 37 | switch (action.type) { 38 | case "SET_VALUES": 39 | return { 40 | ...state, 41 | values: { ...state.values, ...action.payload.values }, 42 | }; 43 | case "SET_SINGLE_TOUCHED": { 44 | const { field, touched } = action.payload; 45 | return { 46 | ...state, 47 | touched: { ...state.touched, [field]: touched }, 48 | }; 49 | } 50 | case "SET_CONTEXT": { 51 | const { context } = action.payload; 52 | return { 53 | ...state, 54 | context, 55 | }; 56 | } 57 | case "VALUE_CHANGE": { 58 | const { field, value, touched } = action.payload; 59 | return { 60 | ...state, 61 | values: { ...state.values, [field]: value }, 62 | touched: { ...state.touched, [field]: touched }, 63 | }; 64 | } 65 | case "VALIDATION_FAILURE": { 66 | const { field, error } = action.payload; 67 | return { 68 | ...state, 69 | errors: { ...state.errors, [field]: error }, 70 | validity: { ...state.validity, [field]: false }, 71 | }; 72 | } 73 | 74 | case "VALIDATION_SUCCESS": { 75 | const { field } = action.payload; 76 | return { 77 | ...state, 78 | errors: { ...state.errors, [field]: undefined }, 79 | validity: { ...state.validity, [field]: true }, 80 | }; 81 | } 82 | case "SUBMITTING": { 83 | return { ...state, submitting: true }; 84 | } 85 | case "FINISHED_SUBMITTING": { 86 | const { errors } = action.payload; 87 | const fields = Object.keys(state.values) as Array; 88 | 89 | const touched = generateAllTouchedTrue(fields); 90 | const validity = deriveValidityFromErrors(fields, errors); 91 | 92 | return { ...state, touched, validity, errors, submitting: false }; 93 | } 94 | case "RESET": { 95 | return { ...action.payload }; 96 | } 97 | default: 98 | return state; 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/hooks/fluent-form/state-manager/useFluentStateManager.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useReducer, useRef } from "react"; 2 | 3 | import { FormConfig } from "../../../form-config/FormConfig"; 4 | import { FormConfigHelper } from "../../../form-config/FormConfigHelper"; 5 | import { 6 | ExtractErrorsType, 7 | ExtractFieldsType, 8 | ExtractValuesType, 9 | FluentFormReducer, 10 | FluentFormState, 11 | UseFluentStateManager, 12 | } from "../../../types"; 13 | import { fluentFormReducer } from "./reducer"; 14 | 15 | export function useFluentStateManager( 16 | config: Config, 17 | ): UseFluentStateManager { 18 | type Values = ExtractValuesType; 19 | type Fields = ExtractFieldsType; 20 | type Errors = ExtractErrorsType; 21 | 22 | const formConfigHelperRef = useRef(new FormConfigHelper(config)); 23 | const { current: formConfigHelper } = formConfigHelperRef; 24 | 25 | const intitalStateRef = useRef>( 26 | formConfigHelper.getInitialState(), 27 | ); 28 | 29 | const [state, dispatch] = useReducer>( 30 | fluentFormReducer, 31 | intitalStateRef.current, 32 | ); 33 | 34 | const setContext = useCallback((context: object) => { 35 | dispatch({ type: "SET_CONTEXT", payload: { context } }); 36 | }, []); 37 | 38 | const setInitialValuesRef = useCallback((values: Partial) => { 39 | intitalStateRef.current.values = { 40 | ...intitalStateRef.current.values, 41 | ...values, 42 | }; 43 | }, []); 44 | 45 | const setSubmittingResult = useCallback((errors: Errors) => { 46 | dispatch({ 47 | type: "FINISHED_SUBMITTING", 48 | payload: { errors: errors }, 49 | }); 50 | }, []); 51 | 52 | const setTouched = useCallback( 53 | (field: K, touched: boolean) => { 54 | dispatch({ type: "SET_SINGLE_TOUCHED", payload: { field, touched } }); 55 | }, 56 | [], 57 | ); 58 | 59 | const setValidationFailure = useCallback( 60 | (field: K, error: Errors[K]) => { 61 | dispatch({ 62 | type: "VALIDATION_FAILURE", 63 | payload: { field, error: error }, 64 | }); 65 | }, 66 | [], 67 | ); 68 | 69 | const setValidationSuccess = useCallback( 70 | (field: K) => { 71 | dispatch({ 72 | type: "VALIDATION_SUCCESS", 73 | payload: { field }, 74 | }); 75 | }, 76 | [], 77 | ); 78 | 79 | const setValue = useCallback( 80 | (field: K, value: Values[K], touched?: boolean) => { 81 | dispatch({ 82 | type: "VALUE_CHANGE", 83 | payload: { field, value, touched }, 84 | }); 85 | }, 86 | [], 87 | ); 88 | 89 | const setValues = useCallback((values: Partial) => { 90 | dispatch({ type: "SET_VALUES", payload: { values } }); 91 | }, []); 92 | 93 | const startSubmitting = useCallback(() => { 94 | dispatch({ type: "SUBMITTING" }); 95 | }, []); 96 | 97 | const reset = useCallback(() => { 98 | dispatch({ type: "RESET", payload: intitalStateRef.current }); 99 | }, []); 100 | 101 | return { 102 | formConfigHelperRef, 103 | state, 104 | setContext, 105 | setInitialValuesRef, 106 | setSubmittingResult, 107 | setTouched, 108 | setValidationFailure, 109 | setValidationSuccess, 110 | setValue, 111 | setValues, 112 | startSubmitting, 113 | reset, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/hooks/fluent-form/useFluentForm.ts: -------------------------------------------------------------------------------- 1 | import { FormConfig } from "../../form-config/FormConfig"; 2 | import { UseFluentForm } from "../../types"; 3 | import { useFluentStateManager } from "./state-manager/useFluentStateManager"; 4 | import { useFluentFormBase } from "./useFluentFormBase"; 5 | 6 | export function useFluentForm( 7 | config: Config, 8 | ): UseFluentForm { 9 | const stateManager = useFluentStateManager(config); 10 | 11 | return useFluentFormBase(config, stateManager); 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/fluent-form/useFluentFormBase.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | import { FormConfig } from "../../form-config/FormConfig"; 4 | import { 5 | ExtractFieldsType, 6 | ExtractValuesType, 7 | MappedFields, 8 | UseFluentForm, 9 | UseFluentStateManager, 10 | } from "../../types"; 11 | import { useHandleSubmit } from "../helper/useHandleSubmit"; 12 | 13 | export function useFluentFormBase( 14 | config: Config, 15 | fluentStateManager: UseFluentStateManager, 16 | ): UseFluentForm { 17 | type Values = ExtractValuesType; 18 | type Fields = ExtractFieldsType; 19 | 20 | const { 21 | formConfigHelperRef: { current: formConfigHelper }, 22 | state, 23 | setContext: setContextState, 24 | setInitialValuesRef, 25 | setSubmittingResult, 26 | setTouched, 27 | setValidationFailure, 28 | setValidationSuccess, 29 | setValue, 30 | setValues, 31 | startSubmitting, 32 | reset, 33 | } = fluentStateManager; 34 | 35 | const { _validateOnContextChange } = config; 36 | 37 | const validateAllFields = useCallback( 38 | (context = state.context) => { 39 | const errors = formConfigHelper.getValidationResultForAllFields( 40 | state.values, 41 | context, 42 | ); 43 | setSubmittingResult(errors); 44 | return errors; 45 | }, 46 | [formConfigHelper, setSubmittingResult, state.values, state.context], 47 | ); 48 | 49 | const validateAllFieldsForUser = useCallback( 50 | () => validateAllFields(), 51 | [validateAllFields], 52 | ); 53 | 54 | const setContext = useCallback( 55 | (context: object) => { 56 | setContextState(context); 57 | if (_validateOnContextChange) { 58 | validateAllFields(context); 59 | } 60 | }, 61 | [setContextState, validateAllFields, _validateOnContextChange], 62 | ); 63 | 64 | const validateField = useCallback( 65 | ( 66 | field: K, 67 | value: Values[K] = state.values[field], 68 | ) => { 69 | const error = formConfigHelper.getValidationResultForField( 70 | field, 71 | value, 72 | state.values, 73 | state.context, 74 | ); 75 | if (error !== undefined) { 76 | setValidationFailure(field, error); 77 | } else { 78 | setValidationSuccess(field); 79 | } 80 | 81 | return error; 82 | }, 83 | [ 84 | formConfigHelper, 85 | state.values, 86 | state.context, 87 | setValidationFailure, 88 | setValidationSuccess, 89 | ], 90 | ); 91 | 92 | const validateFieldForUser = useCallback( 93 | (field: K) => validateField(field), 94 | [validateField], 95 | ); 96 | 97 | const handleChange = useCallback( 98 | (field: K, value: Values[K]) => { 99 | const triggerValidation = formConfigHelper.shouldValidateOnChange( 100 | field, 101 | state.touched[field], 102 | ); 103 | 104 | setValue(field, value, triggerValidation || undefined); 105 | 106 | if (triggerValidation) { 107 | validateField(field, value); 108 | } 109 | }, 110 | [formConfigHelper, setValue, state.touched, validateField], 111 | ); 112 | 113 | const handleTouched = useCallback( 114 | (field: K, value: boolean) => { 115 | if (state.touched[field] !== value) { 116 | setTouched(field, value); 117 | } 118 | 119 | const touchedNow = !state.touched[field]; 120 | 121 | if (formConfigHelper.shouldValidateOnBlur(field, touchedNow)) { 122 | validateField(field, state.values[field]); 123 | } 124 | }, 125 | [formConfigHelper, setTouched, state.touched, state.values, validateField], 126 | ); 127 | 128 | const valid = useMemo( 129 | () => Object.values(state.validity).every((validityValue) => validityValue), 130 | [state.validity], 131 | ); 132 | 133 | const handleSubmit = useHandleSubmit({ 134 | submitting: state.submitting, 135 | valid, 136 | startSubmitting, 137 | submitAction: validateAllFields, 138 | }); 139 | 140 | const mappedFields = useMemo((): MappedFields => { 141 | return formConfigHelper.getMappedFields( 142 | state.values, 143 | handleTouched, 144 | handleChange, 145 | ); 146 | }, [formConfigHelper, state.values, handleTouched, handleChange]); 147 | 148 | return { 149 | ...state, 150 | fields: mappedFields, 151 | setValues, 152 | setInitialValues: setInitialValuesRef, 153 | setContext, 154 | handleSubmit, 155 | validateField: validateFieldForUser, 156 | validateAllFields: validateAllFieldsForUser, 157 | reset, 158 | }; 159 | } 160 | -------------------------------------------------------------------------------- /src/hooks/helper/useEffectIgnoreFirst.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, EffectCallback, useEffect, useRef } from "react"; 2 | 3 | export const useEffectIgnoreFirst = ( 4 | effect: EffectCallback, 5 | deps?: DependencyList | undefined, 6 | ) => { 7 | const didMountRef = useRef(false); 8 | 9 | useEffect(() => { 10 | if (didMountRef.current) { 11 | effect(); 12 | } else { 13 | didMountRef.current = true; 14 | } 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | }, deps); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/helper/useHandleSubmit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | import { 4 | HandleSubmit, 5 | HandleSubmitOptions, 6 | UseHandleSubmitArgs, 7 | } from "../../types"; 8 | import { useEffectIgnoreFirst } from "./useEffectIgnoreFirst"; 9 | 10 | export const useHandleSubmit = ({ 11 | submitting, 12 | valid, 13 | startSubmitting, 14 | submitAction, 15 | }: UseHandleSubmitArgs) => { 16 | /* istanbul ignore next */ 17 | const submitSuccessCallbackRef = useRef(() => undefined); 18 | /* istanbul ignore next */ 19 | const submitFailureCallback = useRef(() => undefined); 20 | // this variable is needed (beside state.submitting) because state updates are async 21 | const submittingRef = useRef(false); 22 | 23 | useEffectIgnoreFirst(() => { 24 | if (submitting) { 25 | submitAction(); 26 | } else { 27 | const { current: success } = submitSuccessCallbackRef; 28 | const { current: failure } = submitFailureCallback; 29 | 30 | // calling the callbacks here will guarantee that state was updated 31 | valid ? success() : failure(); 32 | submittingRef.current = false; 33 | } 34 | // eslint-disable-next-line 35 | }, [submitting]); 36 | 37 | const handleSubmit: HandleSubmit = useCallback( 38 | ( 39 | success: Function = () => undefined, 40 | failure: Function = () => undefined, 41 | options: HandleSubmitOptions = {}, 42 | ) => { 43 | submitSuccessCallbackRef.current = success; 44 | submitFailureCallback.current = failure; 45 | 46 | return (e?: any) => { 47 | const { preventDefault = true, stopPropagation = true } = options; 48 | 49 | if (typeof e === "object") { 50 | if (preventDefault && typeof e.preventDefault === "function") { 51 | e.preventDefault(); 52 | } 53 | if (stopPropagation && typeof e.stopPropagation === "function") { 54 | e.stopPropagation(); 55 | } 56 | } 57 | 58 | if (!submittingRef.current) { 59 | submittingRef.current = true; 60 | startSubmitting(); 61 | } 62 | }; 63 | }, 64 | [startSubmitting], 65 | ); 66 | 67 | return handleSubmit; 68 | }; 69 | -------------------------------------------------------------------------------- /src/hooks/helper/useStateManagerMapper.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | import { FormArrayConfig } from "../../form-config/FormArrayConfig"; 4 | import { 5 | ExtractErrorsType, 6 | ExtractFieldsType, 7 | ExtractValuesType, 8 | FormKey, 9 | UseFluentArrayStateManager, 10 | UseFluentStateManager, 11 | } from "../../types"; 12 | 13 | export function useStateManagerMapper( 14 | key: FormKey, 15 | arrayStateManager: UseFluentArrayStateManager, 16 | ): UseFluentStateManager { 17 | const { 18 | formArray, 19 | formConfigHelperRef, 20 | setContext: setContextWithKey, 21 | setInitialValuesRef: setInitialValuesRefWithKey, 22 | setSubmittingResult: setSubmittingResultWithKey, 23 | setTouched: setTouchedWithKey, 24 | setValidationFailure: setValidationFailureWithKey, 25 | setValidationSuccess: setValidationSuccessWithKey, 26 | setValue: setValueWithKey, 27 | setValues: setValuesWithKey, 28 | startSubmitting: startSubmittingWithKey, 29 | reset: resetWithKey, 30 | } = arrayStateManager; 31 | 32 | const state = useMemo( 33 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 34 | () => formArray.find((formItem) => formItem.key === key)!, 35 | [key, formArray], 36 | ); 37 | 38 | const setContext = useCallback( 39 | (context: object) => setContextWithKey(key, context), 40 | [key, setContextWithKey], 41 | ); 42 | 43 | const setInitialValuesRef = useCallback( 44 | (values: Partial>) => 45 | setInitialValuesRefWithKey(key, values), 46 | [key, setInitialValuesRefWithKey], 47 | ); 48 | 49 | const setSubmittingResult = useCallback( 50 | (errors: ExtractErrorsType) => 51 | setSubmittingResultWithKey(key, errors), 52 | [key, setSubmittingResultWithKey], 53 | ); 54 | 55 | const setTouched = useCallback( 56 | >(field: K, touched: boolean) => 57 | setTouchedWithKey(key, field, touched), 58 | [key, setTouchedWithKey], 59 | ); 60 | 61 | const setValidationFailure = useCallback( 62 | >( 63 | field: K, 64 | error: ExtractErrorsType, 65 | ) => setValidationFailureWithKey(key, field, error), 66 | [key, setValidationFailureWithKey], 67 | ); 68 | 69 | const setValidationSuccess = useCallback( 70 | >(field: K) => 71 | setValidationSuccessWithKey(key, field), 72 | [key, setValidationSuccessWithKey], 73 | ); 74 | 75 | const setValue = useCallback( 76 | >( 77 | field: K, 78 | value: ExtractValuesType[K], 79 | touched?: boolean, 80 | ) => setValueWithKey(key, field, value, touched), 81 | [key, setValueWithKey], 82 | ); 83 | 84 | const setValues = useCallback( 85 | (values: Partial>) => 86 | setValuesWithKey(key, values), 87 | [key, setValuesWithKey], 88 | ); 89 | 90 | const startSubmitting = useCallback( 91 | () => startSubmittingWithKey(key), 92 | [key, startSubmittingWithKey], 93 | ); 94 | 95 | const reset = useCallback(() => resetWithKey(key), [key, resetWithKey]); 96 | 97 | const fluentStateManager: UseFluentStateManager = useMemo( 98 | () => ({ 99 | formConfigHelperRef, 100 | state, 101 | setContext, 102 | setInitialValuesRef, 103 | setSubmittingResult, 104 | setTouched: setTouched, 105 | setValidationFailure, 106 | setValidationSuccess, 107 | setValue, 108 | setValues, 109 | startSubmitting, 110 | reset, 111 | }), 112 | [ 113 | formConfigHelperRef, 114 | reset, 115 | setContext, 116 | setInitialValuesRef, 117 | setSubmittingResult, 118 | setTouched, 119 | setValidationFailure, 120 | setValidationSuccess, 121 | setValue, 122 | setValues, 123 | startSubmitting, 124 | state, 125 | ], 126 | ); 127 | 128 | return fluentStateManager; 129 | } 130 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | 3 | export { addField } from "./configuration/addField"; 4 | 5 | export { ValidationTrigger } from "./constants/validationTrigger"; 6 | 7 | export { field } from "./fields/FieldCreatorInstance"; 8 | export { Field } from "./fields/Field"; 9 | export { FieldCreator } from "./fields/FieldCreator"; 10 | export { RawField } from "./fields/RawField"; 11 | export { SelectField } from "./fields/SelectField"; 12 | export { TextAreaField } from "./fields/TextAreaField"; 13 | export { CheckboxField } from "./fields/input/CheckboxField"; 14 | export { InputField } from "./fields/input/InputField"; 15 | export { RadioField } from "./fields/input/RadioField"; 16 | export { TextField } from "./fields/input/TextField"; 17 | 18 | export { FormArrayConfig } from "./form-config/FormArrayConfig"; 19 | export { FormArrayConfigHelper } from "./form-config/FormArrayConfigHelper"; 20 | export { FormConfig } from "./form-config/FormConfig"; 21 | export { FormConfigHelper } from "./form-config/FormConfigHelper"; 22 | export { createForm, createFormArray } from "./form-config/FormCreators"; 23 | 24 | export { fluentFormReducer } from "./hooks/fluent-form/state-manager/reducer"; 25 | export { useFluentStateManager } from "./hooks/fluent-form/state-manager/useFluentStateManager"; 26 | export { useFluentForm } from "./hooks/fluent-form/useFluentForm"; 27 | export { useFluentFormBase } from "./hooks/fluent-form/useFluentFormBase"; 28 | 29 | export { fluentFormArrayReducer } from "./hooks/fluent-form-array/state-manager/reducer"; 30 | export { useFluentArrayStateManager } from "./hooks/fluent-form-array/state-manager/useFluentArrayStateManager"; 31 | export { useFluentFormArray } from "./hooks/fluent-form-array/useFluentFormArray"; 32 | export { useFluentFormArrayBase } from "./hooks/fluent-form-array/useFluentFormArrayBase"; 33 | export { useFluentFormItem } from "./hooks/fluent-form-array/useFluentFormItem"; 34 | 35 | export { useEffectIgnoreFirst } from "./hooks/helper/useEffectIgnoreFirst"; 36 | export { useHandleSubmit } from "./hooks/helper/useHandleSubmit"; 37 | export { useStateManagerMapper } from "./hooks/helper/useStateManagerMapper"; 38 | 39 | export { isYupSchema } from "./utils/isYupSchema"; 40 | export { 41 | deriveValidityFromErrors, 42 | generateAllTouchedTrue, 43 | } from "./utils/stateUtils"; 44 | 45 | export { DefaultValidator } from "./validation/DefaultValidator"; 46 | export { Validator } from "./validation/Validator"; 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React, { Reducer } from "react"; 2 | import * as yup from "yup"; 3 | import { z } from "zod"; 4 | 5 | import { ValidationTrigger } from "./constants/validationTrigger"; 6 | import { Field } from "./fields/Field"; 7 | import { FormArrayConfig } from "./form-config/FormArrayConfig"; 8 | import { FormArrayConfigHelper } from "./form-config/FormArrayConfigHelper"; 9 | import { FormConfig } from "./form-config/FormConfig"; 10 | import { FormConfigHelper } from "./form-config/FormConfigHelper"; 11 | import { FluentFormArrayActionTypes } from "./hooks/fluent-form-array/state-manager/reducer"; 12 | import { FluentFormActionTypes } from "./hooks/fluent-form/state-manager/reducer"; 13 | 14 | // -------------------- FluentForm -------------------- 15 | 16 | export interface UseFluentForm { 17 | values: ExtractValuesType; 18 | touched: StateTouched>; 19 | validity: StateValidity>; 20 | errors: ExtractErrorsType; 21 | context: object; 22 | submitting: boolean; 23 | fields: MappedFields>; 24 | setValues: (values: Partial>) => void; 25 | setInitialValues: (values: Partial>) => void; 26 | setContext: (context: object) => void; 27 | handleSubmit: HandleSubmit; 28 | validateField: >( 29 | field: K, 30 | ) => ExtractErrorsType[K]; 31 | validateAllFields: () => ExtractErrorsType; 32 | reset: () => void; 33 | } 34 | 35 | // FluentForm: state 36 | 37 | export type StateValidity = { 38 | [K in keyof ValuesType]?: boolean; 39 | }; 40 | export type StateTouched = { 41 | [K in keyof ValuesType]?: boolean; 42 | }; 43 | export type ErrorsType = { 44 | [K in keyof ValuesType]?: E; 45 | }; 46 | 47 | export interface FluentFormState< 48 | ValuesType extends object, 49 | Errors extends ErrorsType, 50 | > { 51 | values: ValuesType; 52 | touched: StateTouched; 53 | validity: StateValidity; 54 | errors: Errors; 55 | context: object; 56 | submitting: boolean; 57 | } 58 | 59 | // FluentForm: reducer 60 | 61 | export type FluentFormReducer< 62 | ValuesType extends object, 63 | Errors extends ErrorsType, 64 | > = Reducer< 65 | FluentFormState, 66 | FluentFormActionTypes 67 | >; 68 | 69 | // FluentForm: state setters 70 | 71 | export type SetValue = (value: V) => void; 72 | export type SetTouched = (value?: boolean) => void; 73 | 74 | // FluentForm: state manager 75 | 76 | export interface UseFluentStateManager { 77 | formConfigHelperRef: React.MutableRefObject>; 78 | state: FluentFormState, ExtractErrorsType>; 79 | setContext: (context: object) => void; 80 | setInitialValuesRef: (values: Partial>) => void; 81 | setSubmittingResult: (errors: ExtractErrorsType) => void; 82 | setTouched: >( 83 | field: K, 84 | touched: boolean, 85 | ) => void; 86 | setValidationFailure: >( 87 | field: K, 88 | error: ExtractErrorsType, 89 | ) => void; 90 | setValidationSuccess: >( 91 | field: K, 92 | ) => void; 93 | setValue: >( 94 | field: K, 95 | value: ExtractValuesType[K], 96 | touched?: boolean, 97 | ) => void; 98 | setValues: (values: Partial>) => void; 99 | startSubmitting: () => void; 100 | reset: () => void; 101 | } 102 | 103 | // -------------------- FluentFormArray -------------------- 104 | 105 | export type FormArrayStates< 106 | T extends object, 107 | E extends ErrorsType, 108 | > = FormItem[]; 109 | 110 | export interface UseFluentFormArray { 111 | formArray: UseFluentFormItemArgs[]; 112 | formStates: FormArrayStates< 113 | ExtractValuesType, 114 | ExtractErrorsType 115 | >; 116 | submitting: boolean; 117 | setInitialArray: (initialArray: ExtractValuesType[]) => void; 118 | addForm: AddForm>; 119 | removeForm: (key: FormKey) => void; 120 | resetArray: () => void; 121 | getFormStateByKey: ( 122 | key: FormKey, 123 | ) => 124 | | FormItem, ExtractErrorsType> 125 | | undefined; 126 | handleSubmit: HandleSubmit; 127 | } 128 | 129 | // FluentFormArray: state 130 | 131 | export interface FluentFormArrayState { 132 | submitting: boolean; 133 | formArray: FormArrayState; 134 | } 135 | 136 | export interface FormArrayState { 137 | [key: string]: FormItem; 138 | } 139 | 140 | export type FluentFormInitialStates = { 141 | [key in FormKey]: FluentFormState< 142 | ExtractValuesType, 143 | ExtractErrorsType 144 | >; 145 | }; 146 | 147 | // FluentFormArray: reducer 148 | 149 | export type FluentFormArrayReducer< 150 | ValuesType extends object, 151 | Errors extends ErrorsType, 152 | > = Reducer< 153 | FluentFormArrayState, 154 | FluentFormArrayActionTypes 155 | >; 156 | 157 | // FluentFormArray: item 158 | 159 | export type FormKey = number | string; 160 | 161 | export interface UseFluentFormItemArgs { 162 | key: FormKey; 163 | config: Config; 164 | stateManager: UseFluentArrayStateManager; 165 | } 166 | 167 | export interface FormItem 168 | extends FluentFormState { 169 | key: FormKey; 170 | sortPosition: number; 171 | } 172 | 173 | export interface UseFluentFormItem 174 | extends UseFluentForm { 175 | key: FormKey; 176 | removeSelf: () => void; 177 | } 178 | 179 | // FluentFormArray: state manager 180 | 181 | export interface UseFluentArrayStateManager { 182 | formConfigHelperRef: React.MutableRefObject>; 183 | formArrayConfigHelperRef: React.MutableRefObject< 184 | FormArrayConfigHelper 185 | >; 186 | initalStateRefs: React.MutableRefObject>; 187 | formArray: FormItem, ExtractErrorsType>[]; 188 | submitting: boolean; 189 | setInitialArrayRef: (initalArray: ExtractValuesType[]) => void; 190 | startSubmittingArray: () => void; 191 | setSubmittingResultForArray: ( 192 | errors: FormArrayError>, 193 | ) => void; 194 | 195 | resetArray: () => void; 196 | setContext: (key: FormKey, context: object) => void; 197 | setInitialValuesRef: ( 198 | key: FormKey, 199 | values: Partial>, 200 | ) => void; 201 | setSubmittingResult: ( 202 | key: FormKey, 203 | errors: ExtractErrorsType, 204 | ) => void; 205 | setTouched: >( 206 | key: FormKey, 207 | field: K, 208 | touched: boolean, 209 | ) => void; 210 | setValidationFailure: >( 211 | key: FormKey, 212 | field: K, 213 | error: ExtractErrorsType[K], 214 | ) => void; 215 | setValidationSuccess: >( 216 | key: FormKey, 217 | field: K, 218 | ) => void; 219 | setValue: >( 220 | key: FormKey, 221 | field: K, 222 | value: ExtractValuesType[K], 223 | touched?: boolean, 224 | ) => void; 225 | setValues: (key: FormKey, values: Partial>) => void; 226 | startSubmitting: (key: FormKey) => void; 227 | reset: (key: FormKey) => void; 228 | addForm: AddForm>; 229 | removeForm: (key: FormKey) => void; 230 | } 231 | 232 | export type FormArrayError> = { 233 | [key in FormKey]: Errors; 234 | }; 235 | 236 | export interface AddFormArgs { 237 | initialValues?: Partial; 238 | key?: FormKey; 239 | } 240 | 241 | export type AddForm = ( 242 | args?: AddFormArgs, 243 | ) => void; 244 | 245 | // -------------------- FormConfig & FormArrayConfig -------------------- 246 | 247 | export interface ShouldValidateOneChangeArgs { 248 | globalTrigger: ValidationTrigger; 249 | fieldTrigger: ValidationTrigger | undefined; 250 | touched: boolean | undefined; 251 | } 252 | 253 | export interface ShouldValidateOnBlurArgs { 254 | globalTrigger: ValidationTrigger; 255 | fieldTrigger: ValidationTrigger | undefined; 256 | touchedNow: boolean | undefined; 257 | } 258 | 259 | export type ExtractFieldsType = 260 | Config extends FormConfig ? FieldsType : never; 261 | 262 | export type ExtractValuesType = 263 | Config extends FormConfig ? ValuesType : never; 264 | 265 | export type ExtractErrorsType = 266 | Config extends FormConfig ? Error : never; 267 | 268 | export type KeyGenerator = (value: ValuesType) => FormKey; 269 | 270 | // -------------------- Fields -------------------- 271 | 272 | export type Fields = { 273 | [K in keyof ValuesType]: Field; 274 | }; 275 | 276 | // Fields: mapToComponentProps 277 | 278 | export interface ComponentPropsMapperArgs { 279 | value: V; 280 | setValue: SetValue; 281 | setTouched: SetTouched; 282 | } 283 | 284 | export type ComponentPropsMapper = ( 285 | args: ComponentPropsMapperArgs, 286 | ) => Props; 287 | 288 | export type MappedFields = { 289 | [K in keyof F]: ReturnType; 290 | }; 291 | 292 | // Fields: input 293 | 294 | export type CheckboxInputType = "checkbox"; 295 | export type RadioInputType = "radio"; 296 | export type ValueInputTypes = 297 | | "color" 298 | | "date" 299 | | "datetime-local" 300 | | "email" 301 | | "image" 302 | | "month" 303 | | "number" 304 | | "password" 305 | | "range" 306 | | "search" 307 | | "tel" 308 | | "text" 309 | | "time" 310 | | "url" 311 | | "week"; 312 | 313 | export type Input = CheckboxInputType | RadioInputType | ValueInputTypes; 314 | 315 | export type InputValue = string | string[] | number | undefined; 316 | 317 | export interface InputProps { 318 | type?: Input; 319 | onChange: (e: React.ChangeEvent) => void; 320 | onBlur: (e?: React.FocusEvent) => void; 321 | } 322 | 323 | export interface InputPropsValue extends InputProps { 324 | value: InputValue; 325 | } 326 | 327 | export interface InputPropsChecked extends InputProps { 328 | checked: boolean; 329 | } 330 | 331 | export interface InputPropsRadio extends InputPropsChecked { 332 | checked: boolean; 333 | name?: string; 334 | } 335 | 336 | export type InputPropsRadioGenerator = (radioValue: string) => InputPropsRadio; 337 | 338 | // Fields: textarea 339 | 340 | export interface TextAreaProps { 341 | value: string; 342 | onChange: (e: React.ChangeEvent) => void; 343 | } 344 | 345 | // Fields: select 346 | 347 | export interface SelectProps { 348 | select: { 349 | value: string; 350 | onChange: (e: React.ChangeEvent) => void; 351 | onBlur: (e: React.FocusEvent) => void; 352 | }; 353 | option: (value: string) => { value: string }; 354 | } 355 | 356 | // Fields: raw 357 | 358 | export type ValueProp = { [key in Name]: V }; 359 | export type OnChangeProp = { 360 | [key in Name]: (newValue: V) => void; 361 | }; 362 | export type OnBlurProp = { [key in Name]: () => void }; 363 | 364 | export type RawProps< 365 | V, 366 | ValueName extends string, 367 | OnChangeName extends string, 368 | OnBlurName extends string, 369 | > = ValueProp & 370 | OnChangeProp & 371 | OnBlurProp; 372 | 373 | // -------------------- Validation -------------------- 374 | 375 | // Validation: DefaultValidator 376 | 377 | export type ValidateFunction< 378 | ValuesType, 379 | K extends keyof ValuesType, 380 | Error = unknown, 381 | Context extends object = any, 382 | > = ( 383 | value: ValuesType[K], 384 | values: ValuesType, 385 | context: Context, 386 | ) => yup.AnySchema | z.Schema | Error | undefined; 387 | 388 | export type Validations = { 389 | [K in keyof ValuesType]?: 390 | | yup.AnySchema 391 | | z.Schema 392 | | ValidateFunction; 393 | }; 394 | 395 | export type DefaultError< 396 | ValuesType extends object, 397 | V extends Validations, 398 | > = { 399 | [K in keyof ValuesType]?: DefaultValidationReturnType; 400 | }; 401 | 402 | export type DefaultValidationReturnType< 403 | VF extends yup.AnySchema | ValidateFunction | z.Schema | undefined, 404 | > = VF extends yup.AnySchema 405 | ? string[] 406 | : VF extends z.Schema 407 | ? z.ZodError 408 | : VF extends ValidateFunction 409 | ? E extends yup.AnySchema 410 | ? string[] | Exclude 411 | : E extends z.Schema 412 | ? z.ZodError 413 | : E 414 | : never; 415 | 416 | export interface ValidateYupSchemaArgs { 417 | value: ValuesType[K]; 418 | values: ValuesType; 419 | schema: yup.AnySchema; 420 | context: object; 421 | } 422 | 423 | export interface ValidateZodSchemaArgs { 424 | value: ValuesType[K]; 425 | schema: z.ZodSchema; 426 | } 427 | 428 | export interface ValidateFunctionArgs< 429 | ValuesType, 430 | K extends keyof ValuesType, 431 | Error, 432 | > { 433 | value: ValuesType[K]; 434 | values: ValuesType; 435 | validate: ValidateFunction; 436 | context: object; 437 | } 438 | 439 | // -------------------- HandleSubmit -------------------- 440 | 441 | export interface HandleSubmitOptions { 442 | preventDefault?: boolean; 443 | stopPropagation?: boolean; 444 | } 445 | 446 | export type HandleSubmit = ( 447 | success?: Function, 448 | failure?: Function, 449 | options?: HandleSubmitOptions, 450 | ) => (e?: any) => void; 451 | 452 | export interface UseHandleSubmitArgs { 453 | submitting: boolean; 454 | valid: boolean; 455 | startSubmitting: () => void; 456 | submitAction: () => void; 457 | } 458 | 459 | // -------------------- Misc -------------------- 460 | 461 | export type Action = Payload extends void 462 | ? { type: K } 463 | : { type: K; payload: Payload }; 464 | -------------------------------------------------------------------------------- /src/utils/isValidateFunction.ts: -------------------------------------------------------------------------------- 1 | import { ValidateFunction } from "../types"; 2 | 3 | export function isValidateFunction( 4 | toCheckObj: any, 5 | ): toCheckObj is ValidateFunction { 6 | return typeof toCheckObj === "function"; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/isYupSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export function isYupSchema(toCheckObj: any): toCheckObj is yup.AnySchema { 4 | return toCheckObj && toCheckObj.__isYupSchema__; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/isZodSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export function isZodSchema(toCheckObj: any): toCheckObj is z.Schema { 4 | return typeof toCheckObj?.safeParse === "function"; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/stateUtils.ts: -------------------------------------------------------------------------------- 1 | import { ErrorsType, StateTouched, StateValidity } from "../types"; 2 | 3 | export function generateAllTouchedTrue(keys: Array) { 4 | const touched: StateTouched = {}; 5 | 6 | for (let i = 0; i < keys.length; i++) { 7 | touched[keys[i]] = true; 8 | } 9 | 10 | return touched; 11 | } 12 | 13 | export function deriveValidityFromErrors( 14 | keys: Array, 15 | errors: ErrorsType, 16 | ) { 17 | const validity: StateValidity = {}; 18 | 19 | for (let i = 0; i < keys.length; i++) { 20 | const key = keys[i]; 21 | validity[key] = errors[key] === undefined; 22 | } 23 | 24 | return validity; 25 | } 26 | -------------------------------------------------------------------------------- /src/validation/DefaultValidator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultError, 3 | DefaultValidationReturnType, 4 | ValidateFunctionArgs, 5 | ValidateYupSchemaArgs, 6 | ValidateZodSchemaArgs, 7 | Validations, 8 | } from "../types"; 9 | import { isYupSchema } from "../utils/isYupSchema"; 10 | import { Validator } from "./Validator"; 11 | import { isZodSchema } from "../utils/isZodSchema"; 12 | import { isValidateFunction } from "../utils/isValidateFunction"; 13 | 14 | export class DefaultValidator< 15 | ValuesType extends object, 16 | V extends Validations = Validations, 17 | > extends Validator> { 18 | protected validations: V; 19 | 20 | constructor(validations: V) { 21 | super(); 22 | 23 | this.validations = validations; 24 | } 25 | 26 | protected validateFunction({ 27 | value, 28 | values, 29 | validate, 30 | context, 31 | }: ValidateFunctionArgs) { 32 | const schemaOrResult = validate(value, values, context); 33 | 34 | if (isYupSchema(schemaOrResult)) { 35 | return this.validateYupSchema({ 36 | value, 37 | values, 38 | schema: schemaOrResult, 39 | context, 40 | }); 41 | } else if (isZodSchema(schemaOrResult)) { 42 | return this.validateZodSchema({ value, schema: schemaOrResult }); 43 | } else { 44 | return schemaOrResult as E | undefined; 45 | } 46 | } 47 | 48 | protected validateYupSchema({ 49 | value, 50 | values, 51 | schema, 52 | context, 53 | }: ValidateYupSchemaArgs) { 54 | try { 55 | schema.validateSync(value, { context: { ...values, ...context } }); 56 | } catch (err: any) { 57 | if (err.name === "ValidationError") { 58 | return err.errors; 59 | } else { 60 | console.warn( 61 | "yup validation threw an error which isn't of type ValidationError:", 62 | err, 63 | ); 64 | } 65 | } 66 | } 67 | 68 | protected validateZodSchema({ 69 | value, 70 | schema, 71 | }: ValidateZodSchemaArgs) { 72 | const result = schema.safeParse(value); 73 | 74 | if (!result.success) { 75 | return result.error; 76 | } 77 | } 78 | 79 | public validateField( 80 | field: K, 81 | values: ValuesType, 82 | context: object = {}, 83 | ): DefaultValidationReturnType | void { 84 | const validate = this.validations[field]; 85 | const value = values[field]; 86 | 87 | if (!validate) return; 88 | 89 | if (isValidateFunction(validate)) { 90 | return this.validateFunction({ 91 | value, 92 | values, 93 | validate: validate, 94 | context, 95 | }); 96 | } else if (isZodSchema(validate)) { 97 | return this.validateZodSchema({ 98 | value, 99 | schema: validate, 100 | }) as DefaultValidationReturnType; // no idea why this assertion is needed; 101 | } else if (isYupSchema(validate)) { 102 | return this.validateYupSchema({ 103 | value, 104 | values, 105 | schema: validate, 106 | context, 107 | }); 108 | } else { 109 | console.warn( 110 | `Expected validation of type function, yup.AnySchema or z.Schema, but received type: ${typeof validate})`, 111 | validate, 112 | ); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/validation/Validator.ts: -------------------------------------------------------------------------------- 1 | import { ErrorsType } from "../types"; 2 | 3 | export abstract class Validator< 4 | ValuesType extends object, 5 | Errors extends ErrorsType, 6 | > { 7 | public abstract validateField( 8 | field: K, 9 | values: ValuesType, 10 | context: object, 11 | ): Errors[K] | void; 12 | 13 | public validateAllFields(values: ValuesType, context: object): Errors { 14 | const fields = Object.keys(values) as (keyof ValuesType)[]; 15 | 16 | return fields.reduce( 17 | (errors, field) => ({ 18 | ...errors, 19 | [field]: this.validateField(field, values, context), 20 | }), 21 | {} as Errors, 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/DefaultValidator.test.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | import { DefaultValidator } from "../src/validation/DefaultValidator"; 4 | import { TestModel } from "./types"; 5 | 6 | describe("DefaultValidator", () => { 7 | let testModel: TestModel; 8 | 9 | beforeEach(() => { 10 | testModel = { 11 | aString: "some-string", 12 | aDate: new Date(1970, 1, 1), 13 | }; 14 | }); 15 | 16 | it("is able to execute validations for all fields (validateAllFields)", () => { 17 | const validator = new DefaultValidator({ 18 | aString: yup.mixed(), 19 | aDate: yup.mixed(), 20 | }); 21 | 22 | validator.validateField = jest.fn(() => 1) as any; 23 | 24 | const result = validator.validateAllFields(testModel, { 25 | context: "context", 26 | }); 27 | 28 | expect(result).toMatchObject({ 29 | aString: 1, 30 | aDate: 1, 31 | }); 32 | expect(validator.validateField).toHaveBeenCalledWith("aString", testModel, { 33 | context: "context", 34 | }); 35 | expect(validator.validateField).toHaveBeenCalledWith("aDate", testModel, { 36 | context: "context", 37 | }); 38 | }); 39 | 40 | describe("validateField", () => { 41 | it("logs a warning when yup throws an error other than ValidationError", () => { 42 | const validator = new DefaultValidator({ 43 | aString: yup.string().when("aString", { 44 | is: () => 1 / 0, 45 | then: "error should be thrown before" as any, 46 | }), 47 | }); 48 | 49 | console.warn = jest.fn(); 50 | 51 | validator.validateField("aString", testModel); 52 | 53 | expect(console.warn).toHaveBeenCalledWith( 54 | expect.any(String), 55 | expect.any(Error), 56 | ); 57 | }); 58 | 59 | it("doesn't validate if neither a function or yup schema was provided and logs warning", () => { 60 | const validator = new DefaultValidator({ 61 | aString: 1 as any, 62 | }); 63 | 64 | console.warn = jest.fn(); 65 | 66 | const result = validator.validateField("aString", testModel); 67 | 68 | expect(result).not.toBeDefined(); 69 | expect(console.warn).toHaveBeenCalledWith(expect.any(String), 1); 70 | }); 71 | 72 | it("returns undefined when no validation was passed ", () => { 73 | const validator = new DefaultValidator({}); 74 | 75 | const result = validator.validateField("aString", testModel); 76 | 77 | expect(result).not.toBeDefined(); 78 | }); 79 | 80 | it("returns result of yup validation properly", () => { 81 | const validator = new DefaultValidator({ 82 | aString: yup.string().min(20), 83 | }); 84 | 85 | const result = validator.validateField("aString", testModel); 86 | 87 | expect(result).toEqual(expect.any(Array)); 88 | }); 89 | 90 | it("returns result of custom validation function", () => { 91 | const validator = new DefaultValidator({ 92 | aString: () => { 93 | return 1; 94 | }, 95 | }); 96 | 97 | const result = validator.validateField("aString", testModel); 98 | 99 | expect(result).toBe(1); 100 | }); 101 | 102 | it("allows to return yup validation conditionally", () => { 103 | const validator = new DefaultValidator({ 104 | aString: (_value, values) => { 105 | if (values.aDate) { 106 | return yup.string().min(15, "conditional yup.min(15) error"); 107 | } else { 108 | return yup.string().min(20, "conditional yup.min(20) error"); 109 | } 110 | }, 111 | }); 112 | 113 | let result = validator.validateField("aString", testModel); 114 | 115 | expect(result).toEqual(["conditional yup.min(15) error"]); 116 | 117 | testModel.aDate = null; 118 | result = validator.validateField("aString", testModel); 119 | 120 | expect(result).toEqual(["conditional yup.min(20) error"]); 121 | }); 122 | 123 | it("considers context when validating with validation function", () => { 124 | const validator = new DefaultValidator({ 125 | aString: (_value, _values, context) => { 126 | if (context.contextValue === 1) { 127 | return yup.string().min(20); 128 | } 129 | }, 130 | }); 131 | 132 | let result = validator.validateField("aString", testModel, { 133 | contextValue: 1, 134 | }); 135 | 136 | expect(result).toMatchObject(expect.any(Array)); 137 | 138 | result = validator.validateField("aString", testModel, { 139 | contextValue: 2, 140 | }); 141 | 142 | expect(result).not.toBeDefined(); 143 | }); 144 | 145 | it("considers context when validating with yup schema", () => { 146 | const validator = new DefaultValidator({ 147 | aDate: yup.date().when("$contextValue", { 148 | is: 1, 149 | then: (schema) => schema.min(new Date(2000, 1, 2)), 150 | }), 151 | }); 152 | 153 | let result = validator.validateField("aDate", testModel, { 154 | contextValue: 1, 155 | }); 156 | 157 | expect(result).toEqual(expect.any(Array)); 158 | 159 | result = validator.validateField("aDate", testModel, { contextValue: 2 }); 160 | 161 | expect(result).not.toBeDefined(); 162 | }); 163 | 164 | it("allows using other fields as yup context for validation", () => { 165 | const validator = new DefaultValidator({ 166 | aDate: yup.date().when("$aString", { 167 | is: "condition", 168 | then: (schema) => schema.min(new Date(2000, 1, 2)), 169 | }), 170 | }); 171 | 172 | let result = validator.validateField("aDate", testModel); 173 | 174 | expect(result).not.toBeDefined(); 175 | 176 | testModel.aString = "condition"; 177 | result = validator.validateField("aDate", testModel); 178 | 179 | expect(result).toEqual(expect.any(Array)); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /test/FormArrayConfigHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { field } from "../src/fields/FieldCreatorInstance"; 2 | import { FormArrayConfig } from "../src/form-config/FormArrayConfig"; 3 | import { FormArrayConfigHelper } from "../src/form-config/FormArrayConfigHelper"; 4 | 5 | describe("FormConfigHelper", () => { 6 | let formArrayConfig: FormArrayConfig; 7 | let formArrayConfigHelper: FormArrayConfigHelper; 8 | 9 | beforeEach(() => { 10 | formArrayConfig = new FormArrayConfig({ 11 | username: field.text("user"), 12 | email: field.email(), 13 | }); 14 | 15 | formArrayConfigHelper = new FormArrayConfigHelper(formArrayConfig); 16 | }); 17 | 18 | describe("getInitialArrayValues", () => { 19 | it("returns empty object when there is no initial array", () => { 20 | const initialArray = formArrayConfigHelper.getInitialArrayValues(); 21 | 22 | expect(initialArray).toEqual({}); 23 | }); 24 | 25 | it("returns empty object when initial array is empty", () => { 26 | formArrayConfig.withInitialArray([]); 27 | 28 | const initialArray = formArrayConfigHelper.getInitialArrayValues(); 29 | 30 | expect(initialArray).toEqual({}); 31 | }); 32 | 33 | it("extracts initial values from object first", () => { 34 | const initialArrayActual = [ 35 | { username: "another-user", email: "user@mail.com" }, 36 | { username: "", email: "" }, 37 | ]; 38 | 39 | formArrayConfig.withInitialArray(initialArrayActual); 40 | 41 | const initialArray = formArrayConfigHelper.getInitialArrayValues(); 42 | 43 | expect(initialArray).toEqual({ 44 | "0": { 45 | key: 0, 46 | sortPosition: 0, 47 | values: { username: "another-user", email: "user@mail.com" }, 48 | touched: {}, 49 | validity: {}, 50 | errors: {}, 51 | submitting: false, 52 | context: {}, 53 | }, 54 | "1": { 55 | key: 1, 56 | sortPosition: 1, 57 | values: { username: "", email: "" }, 58 | touched: {}, 59 | validity: {}, 60 | errors: {}, 61 | submitting: false, 62 | context: {}, 63 | }, 64 | }); 65 | }); 66 | 67 | it("sets key with key generator if it is specified", () => { 68 | const initialArrayActual = [ 69 | { username: "user0", email: "" }, 70 | { username: "user1", email: "" }, 71 | ]; 72 | 73 | formArrayConfig 74 | .withInitialArray(initialArrayActual) 75 | .withKeyGenerator((values) => values.username); 76 | 77 | const initialArray = formArrayConfigHelper.getInitialArrayValues(); 78 | 79 | expect(initialArray).toMatchObject({ user0: {}, user1: {} }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/FormConfigHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { ValidationTrigger } from "../src/constants/validationTrigger"; 2 | import { field } from "../src/fields/FieldCreatorInstance"; 3 | import { FormConfig } from "../src/form-config/FormConfig"; 4 | import { FormConfigHelper } from "../src/form-config/FormConfigHelper"; 5 | import { setTrigger } from "./test-utils/setTrigger"; 6 | 7 | const { AfterTouchOnChange, OnChange, OnSubmitOnly } = ValidationTrigger; 8 | 9 | describe("FormConfigHelper", () => { 10 | describe("getInitialValues", () => { 11 | let formConfig: FormConfig; 12 | let formConfigHelper: FormConfigHelper; 13 | 14 | beforeEach(() => { 15 | formConfig = new FormConfig({ 16 | username: field.text("user"), 17 | email: field.email(), 18 | age: field.number("30"), 19 | }); 20 | 21 | formConfigHelper = new FormConfigHelper(formConfig); 22 | }); 23 | 24 | it("extracts initial values from fields", () => { 25 | const initialValues = formConfigHelper.getInitialValues(); 26 | 27 | expect(initialValues).toMatchObject({ 28 | username: "user", 29 | email: "", 30 | age: "30", 31 | }); 32 | }); 33 | 34 | it("extracts initial values from object first", () => { 35 | formConfig.withInitialValues({ 36 | username: "another-user", 37 | email: "user@mail.com", 38 | age: "25", 39 | }); 40 | 41 | const initialValues = formConfigHelper.getInitialValues(); 42 | 43 | expect(initialValues).toMatchObject({ 44 | username: "another-user", 45 | email: "user@mail.com", 46 | age: "25", 47 | }); 48 | }); 49 | 50 | it("fallbacks to initial values from fields if no value was provided from object", () => { 51 | formConfig.withInitialValues({ 52 | age: "25", 53 | }); 54 | 55 | const initialValues = formConfigHelper.getInitialValues(); 56 | 57 | expect(initialValues).toMatchObject({ 58 | username: "user", 59 | email: "", 60 | age: "25", 61 | }); 62 | }); 63 | }); 64 | 65 | describe("getInitialState", () => { 66 | let formConfig: FormConfig; 67 | let formConfigHelper: FormConfigHelper; 68 | 69 | beforeEach(() => { 70 | formConfig = new FormConfig({ 71 | username: field.text("user"), 72 | email: field.email(), 73 | age: field.number("30"), 74 | }); 75 | 76 | formConfigHelper = new FormConfigHelper(formConfig); 77 | }); 78 | 79 | it("sets defaults properly", () => { 80 | const initialState = formConfigHelper.getInitialState(); 81 | 82 | expect(initialState).toEqual({ 83 | values: { username: "user", email: "", age: "30" }, 84 | touched: {}, 85 | validity: {}, 86 | errors: {}, 87 | context: {}, 88 | submitting: false, 89 | }); 90 | }); 91 | 92 | it("uses initial context from config", () => { 93 | formConfig.withContext({ contextValue: 1 }); 94 | const initialState = formConfigHelper.getInitialState(); 95 | 96 | expect(initialState.context).toEqual({ contextValue: 1 }); 97 | }); 98 | 99 | it("prefers passed initial values", () => { 100 | const initialState = formConfigHelper.getInitialState({ 101 | email: "email@test.com", 102 | age: "50", 103 | }); 104 | 105 | expect(initialState.values).toEqual({ 106 | username: "user", 107 | email: "email@test.com", 108 | age: "50", 109 | }); 110 | }); 111 | }); 112 | 113 | describe("shouldValidateOnChange", () => { 114 | let formConfig: FormConfig; 115 | let helper: FormConfigHelper; 116 | 117 | beforeEach(() => { 118 | formConfig = new FormConfig({ 119 | username: field.text(""), 120 | email: field.text(""), 121 | }); 122 | 123 | helper = new FormConfigHelper(formConfig); 124 | }); 125 | 126 | it("returns false by default when not touched", () => { 127 | expect(helper.shouldValidateOnChange("username", true)).toBeTruthy(); 128 | expect(helper.shouldValidateOnChange("username", false)).toBeFalsy(); 129 | expect(helper.shouldValidateOnChange("username", undefined)).toBeFalsy(); 130 | }); 131 | 132 | it("uses global trigger for fields with no trigger", () => { 133 | formConfig._fields.username.validateOnSubmitOnly(); 134 | 135 | expect(helper.shouldValidateOnChange("username", true)).toBeFalsy(); 136 | expect(helper.shouldValidateOnChange("email", true)).toBeTruthy(); 137 | }); 138 | 139 | test.each< 140 | [ 141 | ValidationTrigger, 142 | ValidationTrigger | undefined, 143 | boolean | undefined, 144 | boolean, 145 | ] 146 | >([ 147 | [AfterTouchOnChange, OnChange, true, true], 148 | [AfterTouchOnChange, OnChange, false, true], 149 | [AfterTouchOnChange, OnChange, undefined, true], 150 | [OnChange, undefined, false, true], 151 | [OnChange, OnSubmitOnly, true, false], 152 | [OnSubmitOnly, AfterTouchOnChange, true, true], 153 | ])( 154 | 'with global trigger "%p", field trigger "%p" and touched "%p" it returns "%p"', 155 | ( 156 | globalTrigger: ValidationTrigger, 157 | fieldTrigger: ValidationTrigger | undefined, 158 | touched: boolean | undefined, 159 | result: boolean, 160 | ) => { 161 | setTrigger(formConfig, globalTrigger); 162 | setTrigger(formConfig._fields.username, fieldTrigger); 163 | 164 | expect(helper.shouldValidateOnChange("username", touched)).toBe(result); 165 | }, 166 | ); 167 | }); 168 | 169 | describe("shouldValidateOnBlur", () => { 170 | let formConfig: FormConfig; 171 | let helper: FormConfigHelper; 172 | 173 | beforeEach(() => { 174 | formConfig = new FormConfig({ 175 | username: field.text(""), 176 | email: field.text(""), 177 | }); 178 | 179 | helper = new FormConfigHelper(formConfig); 180 | }); 181 | 182 | it("returns true by default when touched right now", () => { 183 | expect(helper.shouldValidateOnBlur("username", true)).toBeTruthy(); 184 | expect(helper.shouldValidateOnBlur("username", false)).toBeFalsy(); 185 | }); 186 | 187 | it("uses global trigger for fields with no trigger", () => { 188 | formConfig._fields.username.validateOnSubmitOnly(); 189 | 190 | expect(helper.shouldValidateOnBlur("username", true)).toBeFalsy(); 191 | expect(helper.shouldValidateOnBlur("email", true)).toBeTruthy(); 192 | }); 193 | 194 | test.each< 195 | [ValidationTrigger, ValidationTrigger | undefined, boolean, boolean] 196 | >([ 197 | [OnChange, AfterTouchOnChange, true, true], 198 | [OnChange, AfterTouchOnChange, false, false], 199 | [AfterTouchOnChange, undefined, true, true], 200 | [AfterTouchOnChange, undefined, false, false], 201 | [AfterTouchOnChange, OnChange, true, false], 202 | [AfterTouchOnChange, OnSubmitOnly, true, false], 203 | ])( 204 | 'with global trigger "%p", field trigger "%p" and touched "%p" it returns "%p"', 205 | ( 206 | globalTrigger: ValidationTrigger, 207 | fieldTrigger: ValidationTrigger | undefined, 208 | touched: boolean, 209 | result: boolean, 210 | ) => { 211 | setTrigger(formConfig, globalTrigger); 212 | setTrigger(formConfig._fields.username, fieldTrigger); 213 | 214 | expect(helper.shouldValidateOnBlur("username", touched)).toBe(result); 215 | }, 216 | ); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /test/setupTests.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /test/test-helper/CustomField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "../../src/fields/Field"; 2 | import { ComponentPropsMapper, InputPropsValue } from "../../src/types"; 3 | 4 | export class CustomField extends Field { 5 | constructor(initialValue = "") { 6 | super(initialValue); 7 | } 8 | 9 | public mapToComponentProps: ComponentPropsMapper = ({ 10 | value, 11 | setValue, 12 | setTouched, 13 | }) => ({ 14 | value, 15 | onBlur: () => { 16 | setTouched(); 17 | }, 18 | onChange: (e: React.ChangeEvent) => { 19 | setValue(e.target.value); 20 | setTouched(false); 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /test/test-helper/RequiredValidator.ts: -------------------------------------------------------------------------------- 1 | import { ErrorsType } from "../../src/types"; 2 | import { Validator } from "../../src/validation/Validator"; 3 | 4 | export type RequiredField = { 5 | [K in keyof T]?: "required"; 6 | }; 7 | 8 | export class RequiredValidator extends Validator< 9 | T, 10 | ErrorsType 11 | > { 12 | constructor(private requiredFields: RequiredField) { 13 | super(); 14 | } 15 | 16 | public validateField( 17 | field: K, 18 | values: T, 19 | ): void | "field is required" { 20 | if (this.requiredFields[field] && !values[field]) { 21 | return "field is required"; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/test-utils/renderWithFluentForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { render } from "@testing-library/react"; 4 | 5 | import { FormConfig } from "../../src/form-config/FormConfig"; 6 | import { useFluentForm } from "../../src/hooks/fluent-form/useFluentForm"; 7 | import { FluentFormRef, UiComponentProps } from "../types"; 8 | 9 | export function renderWithFluentForm( 10 | config: C, 11 | UiComponent: React.FC>, 12 | ) { 13 | const fluentFormRef: FluentFormRef = { 14 | current: null as any, 15 | }; 16 | 17 | const Wrapper = () => { 18 | const fluentForm = useFluentForm(config); 19 | fluentFormRef.current = fluentForm; 20 | 21 | return ; 22 | }; 23 | 24 | return { ...render(), fluentFormRef }; 25 | } 26 | -------------------------------------------------------------------------------- /test/test-utils/renderWithFluentItems.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { render } from "@testing-library/react"; 4 | 5 | import { FormArrayConfig } from "../../src/form-config/FormArrayConfig"; 6 | import { useFluentFormArray } from "../../src/hooks/fluent-form-array/useFluentFormArray"; 7 | import { useFluentFormItem } from "../../src/hooks/fluent-form-array/useFluentFormItem"; 8 | import { 9 | FluentFormArrayRef, 10 | FluentFormItemsRef, 11 | FormItemsRefSetterProps, 12 | UiItemComponentProps, 13 | } from "../types"; 14 | 15 | function FormItemsInitializer({ 16 | formItemArgs, 17 | formItemsRef, 18 | Component, 19 | }: FormItemsRefSetterProps) { 20 | const formItem = useFluentFormItem(formItemArgs); 21 | formItemsRef.current[formItemArgs.key] = formItem; 22 | 23 | return ; 24 | } 25 | 26 | export function renderWithFluentFormItems( 27 | config: C, 28 | UiItemComponent: React.FC>, 29 | ) { 30 | const fluentFormArrayRef: FluentFormArrayRef = { 31 | current: null as any, 32 | }; 33 | 34 | const fluentFormItemsRef: FluentFormItemsRef = { 35 | current: {} as any, 36 | }; 37 | 38 | const Wrapper = () => { 39 | const fluentFormArray = useFluentFormArray(config); 40 | fluentFormArrayRef.current = fluentFormArray; 41 | 42 | return ( 43 | <> 44 | {fluentFormArray.formArray.map((formItem) => { 45 | return ( 46 | 52 | ); 53 | })} 54 | 55 | ); 56 | }; 57 | 58 | return { ...render(), fluentFormArrayRef, fluentFormItemsRef }; 59 | } 60 | -------------------------------------------------------------------------------- /test/test-utils/setTrigger.ts: -------------------------------------------------------------------------------- 1 | import { ValidationTrigger } from "../../src/constants/validationTrigger"; 2 | import { Field } from "../../src/fields/Field"; 3 | import { FormConfig } from "../../src/form-config/FormConfig"; 4 | 5 | export function setTrigger( 6 | configOrField: FormConfig | Field, 7 | validationTrigger?: ValidationTrigger, 8 | ) { 9 | switch (validationTrigger) { 10 | case ValidationTrigger.AfterTouchOnChange: 11 | configOrField.validateAfterTouchOnChange(); 12 | break; 13 | case ValidationTrigger.OnChange: 14 | configOrField.validateOnChange(); 15 | break; 16 | case ValidationTrigger.OnSubmitOnly: 17 | configOrField.validateOnSubmitOnly(); 18 | break; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | import { FormArrayConfig } from "../src/form-config/FormArrayConfig"; 2 | import { FormConfig } from "../src/form-config/FormConfig"; 3 | import { 4 | FormKey, 5 | UseFluentForm, 6 | UseFluentFormArray, 7 | UseFluentFormItem, 8 | UseFluentFormItemArgs, 9 | } from "../src/types"; 10 | 11 | // renderWithFluentForm 12 | 13 | export type UiComponentProps = { 14 | fluentForm: UseFluentForm; 15 | }; 16 | 17 | export type FluentFormRef = { 18 | current: UseFluentForm; 19 | }; 20 | 21 | // renderWithFluentFormArray 22 | 23 | export type UiArrayComponentProps = { 24 | fluentFormArray: UseFluentFormArray; 25 | }; 26 | 27 | export type FluentFormArrayRef = { 28 | current: UseFluentFormArray; 29 | }; 30 | 31 | // renderWithFluentFormItem 32 | 33 | export type UiItemComponentProps = { 34 | formItem: UseFluentFormItem; 35 | }; 36 | 37 | export type FluentFormItemsRef = { 38 | current: { [key in FormKey]: UseFluentFormItem }; 39 | }; 40 | 41 | export interface FormItemsRefSetterProps { 42 | formItemsRef: FluentFormItemsRef; 43 | formItemArgs: UseFluentFormItemArgs; 44 | Component: React.FC>; 45 | } 46 | 47 | // model types 48 | 49 | export type TestModel = { 50 | aString: string; 51 | aDate: Date | null; 52 | }; 53 | 54 | export type RegisterModel = { 55 | username: string; 56 | password: string; 57 | }; 58 | 59 | export type UserModel = { 60 | username: string; 61 | email: string; 62 | password: string; 63 | }; 64 | 65 | export type SendEmailModel = { 66 | sendEmail: boolean; 67 | }; 68 | 69 | export type NameModel = { 70 | name: string; 71 | }; 72 | 73 | export type ColorModel = { 74 | color: string; 75 | }; 76 | 77 | export type MultiColorModel = { 78 | firstColor: string; 79 | secondColor: string; 80 | }; 81 | 82 | export type CarModel = { 83 | brand: string; 84 | }; 85 | 86 | export type CommentModel = { 87 | comment: string; 88 | }; 89 | 90 | export type AgeModel = { 91 | age: number; 92 | }; 93 | 94 | export type UsernameModel = { 95 | username: string; 96 | }; 97 | -------------------------------------------------------------------------------- /test/useFluentForm-fields.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { fireEvent } from "@testing-library/react"; 4 | import { act, renderHook } from "@testing-library/react"; 5 | 6 | import { field } from "../src/fields/FieldCreatorInstance"; 7 | import { createForm } from "../src/form-config/FormCreators"; 8 | import { useFluentForm } from "../src/hooks/fluent-form/useFluentForm"; 9 | import { renderWithFluentForm } from "./test-utils/renderWithFluentForm"; 10 | import { 11 | AgeModel, 12 | CarModel, 13 | ColorModel, 14 | CommentModel, 15 | MultiColorModel, 16 | NameModel, 17 | SendEmailModel, 18 | } from "./types"; 19 | 20 | describe("useFluentForm (fields)", () => { 21 | describe("text", () => { 22 | it("is by default not touched and empty string", () => { 23 | const formConfig = createForm()({ 24 | name: field.text(), 25 | }); 26 | 27 | const { fluentFormRef } = renderWithFluentForm( 28 | formConfig, 29 | ({ fluentForm }) => , 30 | ); 31 | expect(fluentFormRef.current.values.name).toBe(""); 32 | expect(fluentFormRef.current.touched.name).toBeFalsy(); 33 | }); 34 | 35 | it("allows passing initial value", () => { 36 | const formConfig = createForm()({ 37 | name: field.text("ysfaran"), 38 | }); 39 | 40 | const { fluentFormRef, queryByDisplayValue } = renderWithFluentForm( 41 | formConfig, 42 | ({ fluentForm }) => , 43 | ); 44 | 45 | expect(fluentFormRef.current.values.name).toBe("ysfaran"); 46 | expect(fluentFormRef.current.touched.name).toBeFalsy(); 47 | expect(queryByDisplayValue("ysfaran")); 48 | }); 49 | 50 | it("is touched when blurred", () => { 51 | const formConfig = createForm()({ 52 | name: field.text(), 53 | }); 54 | 55 | const { container, fluentFormRef } = renderWithFluentForm( 56 | formConfig, 57 | ({ fluentForm }) => , 58 | ); 59 | 60 | const input = container.querySelector("input")!; 61 | 62 | fireEvent.blur(input); 63 | 64 | expect(fluentFormRef.current.touched.name).toBeTruthy(); 65 | }); 66 | 67 | it("updates state and input value on change", () => { 68 | const formConfig = createForm<{ name: string }>()({ 69 | name: field.text(), 70 | }); 71 | 72 | const { container, fluentFormRef, queryByDisplayValue } = 73 | renderWithFluentForm(formConfig, ({ fluentForm }) => ( 74 | 75 | )); 76 | const input = container.querySelector("input")!; 77 | 78 | fireEvent.change(input, { target: { value: "ysfaran" } }); 79 | 80 | expect(fluentFormRef.current.values.name).toBe("ysfaran"); 81 | expect(queryByDisplayValue("ysfaran")); 82 | }); 83 | }); 84 | 85 | describe("checkbox", () => { 86 | it("is by default not touched and false", () => { 87 | const formConfig = createForm()({ 88 | sendEmail: field.checkbox().type("checkbox"), // for 100% coverage 89 | }); 90 | 91 | const { fluentFormRef } = renderWithFluentForm( 92 | formConfig, 93 | ({ fluentForm }) => , 94 | ); 95 | 96 | expect(fluentFormRef.current.values.sendEmail).toBeFalsy(); 97 | expect(fluentFormRef.current.touched.sendEmail).toBeFalsy(); 98 | }); 99 | 100 | it("allows passing initial value", () => { 101 | const formConfig = createForm()({ 102 | sendEmail: field.checkbox(true), 103 | }); 104 | 105 | const { container, fluentFormRef } = renderWithFluentForm( 106 | formConfig, 107 | ({ fluentForm }) => , 108 | ); 109 | 110 | const input = container.querySelector("input")!; 111 | 112 | expect(fluentFormRef.current.values.sendEmail).toBeTruthy(); 113 | expect(fluentFormRef.current.touched.sendEmail).toBeFalsy(); 114 | expect(input.checked).toBeTruthy(); 115 | }); 116 | 117 | it("is touched when blurred", () => { 118 | const formConfig = createForm()({ 119 | sendEmail: field.checkbox(), 120 | }); 121 | 122 | const { container, fluentFormRef } = renderWithFluentForm( 123 | formConfig, 124 | ({ fluentForm }) => , 125 | ); 126 | 127 | const input = container.querySelector("input")!; 128 | 129 | fireEvent.blur(input); 130 | 131 | expect(fluentFormRef.current.touched.sendEmail).toBeTruthy(); 132 | }); 133 | 134 | it("updates state and input value on change", () => { 135 | const formConfig = createForm()({ 136 | sendEmail: field.checkbox(), 137 | }); 138 | 139 | const { container, fluentFormRef } = renderWithFluentForm( 140 | formConfig, 141 | ({ fluentForm }) => , 142 | ); 143 | const input = container.querySelector("input")!; 144 | 145 | fireEvent.click(input); 146 | 147 | expect(fluentFormRef.current.values.sendEmail).toBeTruthy(); 148 | expect(input.checked).toBeTruthy(); 149 | }); 150 | }); 151 | 152 | describe("radio", () => { 153 | it("is by default not touched and empty string", () => { 154 | const formConfig = createForm()({ 155 | color: field 156 | .radio() 157 | .name("color") 158 | .type("radio") // for 100% coverage 159 | .unselectable(false), // for 100% coverage 160 | }); 161 | 162 | const { fluentFormRef } = renderWithFluentForm( 163 | formConfig, 164 | ({ fluentForm }) => ( 165 | <> 166 | 167 | 168 | 169 | 170 | ), 171 | ); 172 | 173 | expect(fluentFormRef.current.values.color).toBe(""); 174 | expect(fluentFormRef.current.touched.color).toBeFalsy(); 175 | }); 176 | 177 | it("allows passing initial value", () => { 178 | const formConfig = createForm()({ 179 | color: field.radio("green"), 180 | }); 181 | 182 | const { container, fluentFormRef } = renderWithFluentForm( 183 | formConfig, 184 | ({ fluentForm }) => ( 185 | <> 186 | 187 | 188 | 189 | 190 | ), 191 | ); 192 | 193 | const inputs = container.querySelectorAll("input"); 194 | 195 | expect(fluentFormRef.current.values.color).toBe("green"); 196 | expect(inputs[1].checked).toBeTruthy(); 197 | expect(fluentFormRef.current.touched.color).toBeFalsy(); 198 | }); 199 | 200 | it("is touched when any radio button blurres", () => { 201 | const formConfig = createForm()({ 202 | color: field.radio().name("color"), 203 | }); 204 | 205 | const { container, fluentFormRef } = renderWithFluentForm( 206 | formConfig, 207 | ({ fluentForm }) => ( 208 | <> 209 | 210 | 211 | 212 | 213 | ), 214 | ); 215 | 216 | const inputs = container.querySelectorAll("input"); 217 | 218 | fireEvent.blur(inputs[0]); 219 | 220 | expect(fluentFormRef.current.touched.color).toBeTruthy(); 221 | }); 222 | 223 | it("updates state and input checked on change", () => { 224 | const formConfig = createForm()({ 225 | color: field.radio().name("color"), 226 | }); 227 | 228 | const { container, fluentFormRef } = renderWithFluentForm( 229 | formConfig, 230 | ({ fluentForm }) => ( 231 | <> 232 | 233 | 234 | 235 | 236 | ), 237 | ); 238 | const inputs = container.querySelectorAll("input"); 239 | 240 | fireEvent.click(inputs[0]); 241 | 242 | expect(fluentFormRef.current.values.color).toBe("red"); 243 | expect(inputs[0].checked).toBeTruthy(); 244 | expect(inputs[1].checked).toBeFalsy(); 245 | expect(inputs[2].checked).toBeFalsy(); 246 | 247 | fireEvent.click(inputs[2]); 248 | 249 | expect(fluentFormRef.current.values.color).toBe("blue"); 250 | expect(inputs[0].checked).toBeFalsy(); 251 | expect(inputs[1].checked).toBeFalsy(); 252 | expect(inputs[2].checked).toBeTruthy(); 253 | }); 254 | 255 | it("is by default not unselectable", () => { 256 | const formConfig = createForm()({ 257 | color: field.radio().name("color"), 258 | }); 259 | 260 | const { container, fluentFormRef } = renderWithFluentForm( 261 | formConfig, 262 | ({ fluentForm }) => ( 263 | <> 264 | 265 | 266 | 267 | 268 | ), 269 | ); 270 | 271 | const inputs = container.querySelectorAll("input"); 272 | 273 | fireEvent.click(inputs[0]); 274 | fireEvent.click(inputs[0]); 275 | 276 | expect(fluentFormRef.current.values.color).toBe("red"); 277 | }); 278 | 279 | it("can optionally be made unselectable", () => { 280 | const formConfig = createForm()({ 281 | color: field.radio().name("color").unselectable(), 282 | }); 283 | 284 | const { container, fluentFormRef } = renderWithFluentForm( 285 | formConfig, 286 | ({ fluentForm }) => ( 287 | <> 288 | 289 | 290 | 291 | 292 | ), 293 | ); 294 | 295 | const inputs = container.querySelectorAll("input"); 296 | 297 | fireEvent.click(inputs[0]); 298 | fireEvent.click(inputs[0]); 299 | 300 | expect(fluentFormRef.current.values.color).toBe(""); 301 | }); 302 | 303 | it("works with multiple radio button groups", () => { 304 | const formConfig = createForm()({ 305 | firstColor: field.radio().name("color1"), 306 | secondColor: field.radio().name("color2"), 307 | }); 308 | 309 | const { container, fluentFormRef } = renderWithFluentForm( 310 | formConfig, 311 | ({ fluentForm }) => ( 312 | <> 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | ), 321 | ); 322 | 323 | expect(fluentFormRef.current.values.firstColor).toBe(""); 324 | expect(fluentFormRef.current.touched.firstColor).toBeFalsy(); 325 | 326 | const inputs = container.querySelectorAll("input"); 327 | 328 | fireEvent.click(inputs[1]); 329 | fireEvent.blur(inputs[1]); 330 | fireEvent.click(inputs[5]); 331 | fireEvent.blur(inputs[5]); 332 | 333 | expect(fluentFormRef.current.values.firstColor).toBe("green"); 334 | expect(fluentFormRef.current.touched.firstColor).toBeTruthy(); 335 | expect(inputs[0].checked).toBeFalsy(); 336 | expect(inputs[1].checked).toBeTruthy(); 337 | expect(inputs[2].checked).toBeFalsy(); 338 | 339 | expect(fluentFormRef.current.values.secondColor).toBe("blue"); 340 | expect(fluentFormRef.current.touched.secondColor).toBeTruthy(); 341 | expect(inputs[3].checked).toBeFalsy(); 342 | expect(inputs[4].checked).toBeFalsy(); 343 | expect(inputs[5].checked).toBeTruthy(); 344 | }); 345 | }); 346 | 347 | describe("select", () => { 348 | it("is by default not touched and empty string", () => { 349 | const formConfig = createForm()({ 350 | brand: field.select(), 351 | }); 352 | 353 | const { fluentFormRef } = renderWithFluentForm( 354 | formConfig, 355 | ({ fluentForm }) => ( 356 | 361 | ), 362 | ); 363 | 364 | expect(fluentFormRef.current.values.brand).toBe(""); 365 | expect(fluentFormRef.current.touched.brand).toBeFalsy(); 366 | }); 367 | 368 | it("allows passing initial value", () => { 369 | const formConfig = createForm()({ 370 | brand: field.select("BMW"), 371 | }); 372 | 373 | const { fluentFormRef, queryByDisplayValue } = renderWithFluentForm( 374 | formConfig, 375 | ({ fluentForm }) => ( 376 | 381 | ), 382 | ); 383 | 384 | expect(fluentFormRef.current.values.brand).toBe("BMW"); 385 | expect(queryByDisplayValue("BMW")); 386 | expect(fluentFormRef.current.touched.brand).toBeFalsy(); 387 | }); 388 | 389 | it("is touched when blurred", () => { 390 | const formConfig = createForm()({ 391 | brand: field.select(), 392 | }); 393 | 394 | const { container, fluentFormRef } = renderWithFluentForm( 395 | formConfig, 396 | ({ fluentForm }) => ( 397 | 402 | ), 403 | ); 404 | 405 | const select = container.querySelector("select")!; 406 | 407 | fireEvent.blur(select); 408 | 409 | expect(fluentFormRef.current.touched.brand).toBeTruthy(); 410 | }); 411 | 412 | it("updates state and input value on change", () => { 413 | const formConfig = createForm()({ 414 | brand: field.select(), 415 | }); 416 | 417 | const { container, fluentFormRef, queryByDisplayValue } = 418 | renderWithFluentForm(formConfig, ({ fluentForm }) => ( 419 | 424 | )); 425 | const select = container.querySelector("select")!; 426 | 427 | fireEvent.change(select, { target: { value: "BMW" } }); 428 | 429 | expect(fluentFormRef.current.values.brand).toBe("BMW"); 430 | expect(queryByDisplayValue("BMW")); 431 | }); 432 | }); 433 | 434 | describe("textarea", () => { 435 | it("is by default not touched and empty string", () => { 436 | const formConfig = createForm()({ 437 | comment: field.textarea(), 438 | }); 439 | 440 | const { fluentFormRef } = renderWithFluentForm( 441 | formConfig, 442 | ({ fluentForm }) =>