├── .editorconfig ├── .github └── workflows │ ├── build.yml │ ├── main.yml │ ├── pr-check-suite.yml │ ├── pr-depfu-merge.yml │ ├── release-tag.yml │ ├── sonarcloud.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CREDITS.md ├── LICENSE ├── README.md ├── docs ├── VvForm.md ├── VvFormField.md ├── VvFormTemplate.md ├── VvFormWrapeer.md └── static │ ├── 8wave.svg │ └── volverjs-form.svg ├── eslint.config.js ├── jest.config.cjs ├── package.json ├── playwright-ct.config.ts ├── playwright ├── index.html └── index.ts ├── pnpm-lock.yaml ├── sonar-project.properties ├── src ├── VvForm.ts ├── VvFormField.ts ├── VvFormFieldsGroup.ts ├── VvFormTemplate.ts ├── VvFormWrapper.ts ├── enums.ts ├── index.ts ├── shims.d.ts ├── types.ts └── utils.ts ├── test-playwright ├── VvForm.spec.ts ├── VvForm.vue ├── VvFormField.spec.ts ├── VvFormField.vue ├── VvFormFieldsGroup.spec.ts ├── VvFormFieldsGroup.vue ├── VvFormTemplate.spec.ts ├── VvFormTemplate.vue ├── VvFormWrapper.spec.ts ├── VvFormWrapper.vue └── components │ ├── NameSurname.vue │ └── ScopedSlot.vue ├── test-vitest ├── defaultObjectBySchema.test.ts └── useForm.test.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tabs 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | indent_style = space 15 | indent_size = 2 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build library 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: pnpm/action-setup@v4 13 | 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: 'pnpm' 19 | 20 | - name: Install dependencies 21 | run: pnpm install --no-frozen-lockfile 22 | 23 | - name: Build release 24 | run: pnpm build 25 | 26 | - name: Bump version with release tag name 27 | run: pnpm version --no-git-tag-version ${{ github.event.release.tag_name }} 28 | 29 | - name: Pack package 30 | run: pnpm pack 31 | 32 | - name: Upload artifact 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: package 36 | path: '*.tgz' 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main pipeline 2 | 3 | on: 4 | # Runs on release publish 5 | release: 6 | types: [published] 7 | 8 | permissions: 9 | contents: read 10 | id-token: write 11 | pull-requests: read 12 | 13 | jobs: 14 | # Sonarcloud analysis 15 | analysis: 16 | uses: ./.github/workflows/sonarcloud.yml 17 | secrets: inherit 18 | 19 | # Build package 20 | build: 21 | uses: ./.github/workflows/build.yml 22 | 23 | # Run test 24 | test: 25 | needs: build 26 | uses: ./.github/workflows/test.yml 27 | 28 | # Publish package to NPM 29 | publish-npm: 30 | needs: [test, analysis] 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Download artifact 34 | uses: actions/download-artifact@v4 35 | with: 36 | name: package 37 | - uses: actions/setup-node@v4 38 | with: 39 | node-version: 20 40 | registry-url: https://registry.npmjs.org/ 41 | - run: npm publish $(ls *.tgz) --access=public --tag ${{ github.event.release.prerelease && 'next' || 'latest'}} 42 | env: 43 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 44 | 45 | # Publish package to GPR 46 | publish-gpr: 47 | needs: [test, analysis] 48 | runs-on: ubuntu-latest 49 | permissions: 50 | contents: read 51 | packages: write 52 | steps: 53 | - name: Download artifact 54 | uses: actions/download-artifact@v4 55 | with: 56 | name: package 57 | - uses: actions/setup-node@v4 58 | with: 59 | node-version: 20 60 | registry-url: https://npm.pkg.github.com/ 61 | - run: npm publish $(ls *.tgz) --access=public --tag ${{ github.event.release.prerelease && 'next' || 'latest'}} 62 | env: 63 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 64 | -------------------------------------------------------------------------------- /.github/workflows/pr-check-suite.yml: -------------------------------------------------------------------------------- 1 | name: Check PR 2 | 3 | on: 4 | # Run on pull request 5 | pull_request: 6 | branches: [main, develop] 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: write 11 | pages: write 12 | id-token: write 13 | pull-requests: write 14 | 15 | jobs: 16 | # Sonarcloud analysis 17 | analysis: 18 | uses: ./.github/workflows/sonarcloud.yml 19 | secrets: inherit 20 | 21 | # CI 22 | build: 23 | uses: ./.github/workflows/build.yml 24 | 25 | # Storybook test on vercel env 26 | test: 27 | needs: build 28 | uses: ./.github/workflows/test.yml 29 | 30 | # Trigger depfu merge 31 | trigger-automerge: 32 | needs: [test, analysis] 33 | uses: ./.github/workflows/pr-depfu-merge.yml 34 | -------------------------------------------------------------------------------- /.github/workflows/pr-depfu-merge.yml: -------------------------------------------------------------------------------- 1 | name: Merge Depfu PR 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | pull-requests: write 8 | contents: write 9 | 10 | jobs: 11 | approve-pr: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.event.pull_request.user.login == 'depfu[bot]' }} 14 | steps: 15 | - name: Approve PR 16 | run: gh pr review --approve "$PR_URL" 17 | env: 18 | PR_URL: ${{github.event.pull_request.html_url}} 19 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 20 | 21 | merge-pr: 22 | runs-on: ubuntu-latest 23 | if: ${{ github.event.pull_request.user.login == 'depfu[bot]' }} 24 | steps: 25 | - name: Squash PR 26 | run: gh pr merge --auto --squash "$PR_URL" 27 | env: 28 | PR_URL: ${{github.event.pull_request.html_url}} 29 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 30 | -------------------------------------------------------------------------------- /.github/workflows/release-tag.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | build: 10 | permissions: 11 | contents: write # to create release (yyx990803/release-tag) 12 | 13 | name: Create Release for Tag 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@master 18 | 19 | - name: Create Release for Tag 20 | id: release_tag 21 | uses: yyx990803/release-tag@master 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.RELEASE_TAG_GITHUB_TOKEN }} 24 | with: 25 | tag_name: ${{ github.ref }} 26 | body: | 27 | Please refer to [CHANGELOG.md](https://github.com/volverjs/form-vue/blob/${{ contains(github.ref, 'beta') && 'develop' || 'main'}}/CHANGELOG.md) for details. 28 | -------------------------------------------------------------------------------- /.github/workflows/sonarcloud.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud analysis 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | sonarcloud: 8 | name: SonarCloud 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 14 | 15 | - name: SonarCloud Scan 16 | uses: SonarSource/sonarqube-scan-action@master 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 19 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run library test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | playwright: 8 | timeout-minutes: 60 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | 17 | - uses: pnpm/action-setup@v4 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'pnpm' 24 | 25 | - name: Install dependencies 26 | run: pnpm install --no-frozen-lockfile 27 | 28 | - name: Build release 29 | run: pnpm build 30 | 31 | - name: Install Playwright Browsers 32 | run: npx playwright install --with-deps 33 | 34 | - name: Run Playwright tests 35 | run: pnpm test-playwright 36 | 37 | - uses: actions/upload-artifact@v4 38 | if: always() 39 | with: 40 | name: playwright-report 41 | path: playwright-report/ 42 | retention-days: 30 43 | 44 | vitest: 45 | timeout-minutes: 60 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - uses: actions/setup-node@v4 51 | with: 52 | node-version: 20 53 | 54 | - uses: pnpm/action-setup@v4 55 | 56 | - name: Use Node.js ${{ matrix.node-version }} 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: ${{ matrix.node-version }} 60 | cache: 'pnpm' 61 | 62 | - name: Install dependencies 63 | run: pnpm install --no-frozen-lockfile 64 | 65 | - name: Build release 66 | run: pnpm build 67 | 68 | - name: Run Vitest test 69 | run: pnpm test-vitest 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | npm-debug.log* 3 | 4 | node_modules 5 | .DS_Store 6 | dist 7 | coverage 8 | .eslintcache 9 | 10 | # Editor directories and files 11 | /test-results/ 12 | /playwright-report/ 13 | /playwright/.cache/ 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # fix playwright test with pnpm 2 | # @see https://pnpm.io/it/npmrc#shamefully-hoist 3 | # @see https://stackoverflow.com/questions/70597494/pnpm-does-not-resolve-dependencies 4 | node-linker = hoisted -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | // (remove this if your ESLint extension above v3.0.5) 4 | "eslint.useFlatConfig": true, 5 | 6 | // Disable the default formatter, use eslint instead 7 | "prettier.enable": false, 8 | "editor.formatOnSave": false, 9 | 10 | // Auto fix 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit", 13 | "source.organizeImports": "never" 14 | }, 15 | 16 | // Silent the stylistic rules in you IDE, but still auto fix them 17 | "eslint.rules.customizations": [ 18 | { "rule": "style/*", "severity": "off" }, 19 | { "rule": "format/*", "severity": "off" }, 20 | { "rule": "*-indent", "severity": "off" }, 21 | { "rule": "*-spacing", "severity": "off" }, 22 | { "rule": "*-spaces", "severity": "off" }, 23 | { "rule": "*-order", "severity": "off" }, 24 | { "rule": "*-dangle", "severity": "off" }, 25 | { "rule": "*-newline", "severity": "off" }, 26 | { "rule": "*quotes", "severity": "off" }, 27 | { "rule": "*semi", "severity": "off" } 28 | ], 29 | 30 | // Enable eslint for all supported languages 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml", 42 | "toml", 43 | "xml", 44 | "gql", 45 | "graphql", 46 | "astro", 47 | "css", 48 | "less", 49 | "scss", 50 | "pcss", 51 | "postcss" 52 | ], 53 | 54 | // Enable SonarLint 55 | "sonarlint.connectedMode.project": { 56 | "connectionId": "volverjs", 57 | "projectKey": "volverjs_form-vue" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.0.0] - 2024-03-25 6 | 7 | ### Added 8 | 9 | - `submit()` and `validate()` methods are now exposed directly by `useForm()`; 10 | - `ignoreUpdates()` and `stopUpdatesWatch()` methods are now exposed by `useForm()` and in `VvForm` component default slot scope; 11 | - `VvForm` component `tag` prop; 12 | - `invalid` ref is now exposed by `useForm()`; 13 | - `readonly` prop in `VvForm` component; 14 | - `readonly` prop in `VvFormWrapper` component; 15 | - Support for zod async refines with [`safeParseAsync()`](https://zod.dev/?id=safeparseasync). 16 | - `VvFormTemplate` default slot scope for vvChildren; 17 | - `reset()` method to `VvForm` component and `useForm()` to reset form values; 18 | - `clear()` method to `VvForm` component and `useForm()` to clear errors; 19 | - `VvFormWrapper` component `validateWrapper()` method for partial validation; 20 | - `VvFormFieldsGroup` component for grouping fields; 21 | - Custom form object constructor with `class` option in `useForm()` and `createForm()`; 22 | - Expose `wrappers` Map in `useForm()` and `createForm()` to manage form wrappers; 23 | - Singleton form with `scope` option in `useForm()` and `createForm()`. 24 | 25 | ### Changed 26 | 27 | - `VvForm` prop `updateThrottle` is not available anymore, use `updateThrottle` option of `useForm()` instead; 28 | - `submit()` and `validate()` methods of `VvForm` component now return a `Promise` of `boolean` instead of `boolean` directly; 29 | - `continuos-validation` option is now `continuous-validation`. 30 | 31 | ## [0.0.14] - 2023-08-03 32 | 33 | ### Added 34 | 35 | - Pass default slot to `VvFormTemplate` component from `VvForm` component with `template` prop; 36 | - Expose type `FormSchema`; 37 | - `template` prop to `VvForm` component; 38 | - `template` option to `createForm()` and `useForm()` functions; 39 | - Replace `vue-tsc` with `vite-plugin-dts` for types generation. 40 | 41 | ### Fixed 42 | 43 | - `defaultObjectBySchema()` improved support for `ZodDefault` and `ZodArray`; 44 | - Dependencies update. 45 | 46 | ## [0.0.13] - 2023-05-19 47 | 48 | ### Fixed 49 | 50 | - `VvFormTemplate` and `VvFormField` support for `ref()` props; 51 | - `VvFormField` datetime correct type is `datetime-local`. 52 | 53 | ### Added 54 | 55 | - `@volverjs/ui-vue` to `v0.0.8-beta.4` and added to peerDependencies 56 | 57 | ## [0.0.12] - 2023-05-16 58 | 59 | ### Fixed 60 | 61 | - `defaultObjectBySchema()` support for nested `ZodOptional`; 62 | 63 | ## [0.0.11] - 2023-05-16 64 | 65 | ### Fixed 66 | 67 | - `VvFormField` type `select` 68 | 69 | ## [0.0.10] - 2023-05-03 70 | 71 | ### Fixed 72 | 73 | - `VvForm` bug with emit update on zod parsed result; 74 | - `defaultObjectBySchema()` support for nested `ZodEffects`; 75 | - `defaultObjectBySchema()` safe parse of `ZodEffects`; 76 | - `formFactory()` deprecated, use `useForm()` instead; 77 | - Experimental components slots types; 78 | - Typescript improvements. 79 | 80 | ### Added 81 | 82 | - `validate()` method is now exposed by `VvForm` component; 83 | - Add `formData` and `errors` to `VvFormWrapper` default slot scope; 84 | - Add `validate()` and `submit()` to `VvFormWrapper` and `VvFormField` default slot scope; 85 | - `VvFormTemplate` component for template based forms; 86 | - `formFactory()` now export `errors`, `status` and `formData`; 87 | 88 | ## [0.0.9] - 2023-03-23 89 | 90 | ### Added 91 | 92 | - Test: VvForm, VvFormWrapper, VvFormField 93 | 94 | ## [0.0.5] - 2023-03-17 95 | 96 | ### Fixed 97 | 98 | - `defaultObjectBySchema` original value handling and validation; 99 | - Dependency update; 100 | - tsconfig.json new property `verbatimModuleSyntax` replaces `isolatedModules`, `preserveValueImports` and `importsNotUsedAsValues`. 101 | 102 | ### Added 103 | 104 | - `defaultObjectBySchema` tests. 105 | 106 | ## [0.0.4] - 2023-03-16 107 | 108 | ### Doc 109 | 110 | Update docs with: 111 | 112 | - `continuousValidation` prop/option; 113 | - Nested `VvFormField`. 114 | 115 | ## [0.0.3] - 2023-03-15 116 | 117 | ### Fixed 118 | 119 | - Manage Zod `superRefine` validation. 120 | 121 | ### Added 122 | 123 | - Continuous validation feature available with `continuousValidation` option. 124 | 125 | ## [0.0.2] - 2023-03-10 126 | 127 | ### Fixed 128 | 129 | - Remove of unused dependencies. 130 | 131 | ### Added 132 | 133 | - Types of components. 134 | 135 | ## 0.0.1 - 2023-03-09 136 | 137 | ### Added 138 | 139 | - `createForm` function to create a Vue 3 plugin for a set of globally defined options and components. 140 | - `useForm` function to create a form from a Zod schema inside a component. 141 | - `formFactory` function to create a form from a Zod schema outside of a component. 142 | - README, CHANGELOG and LICENSE files. 143 | 144 | [1.0.0]: https://github.com/volverjs/form-vue/compare/v0.0.14...v1.0.0 145 | [0.0.14]: https://github.com/volverjs/form-vue/compare/v0.0.13...v0.0.14 146 | [0.0.13]: https://github.com/volverjs/form-vue/compare/v0.0.12...v0.0.13 147 | [0.0.12]: https://github.com/volverjs/form-vue/compare/v0.0.11...v0.0.12 148 | [0.0.11]: https://github.com/volverjs/form-vue/compare/v0.0.10...v0.0.11 149 | [0.0.10]: https://github.com/volverjs/form-vue/compare/v0.0.9...v0.0.10 150 | [0.0.9]: https://github.com/volverjs/form-vue/compare/v0.0.5...v0.0.9 151 | [0.0.5]: https://github.com/volverjs/form-vue/compare/v0.0.4...v0.0.5 152 | [0.0.4]: https://github.com/volverjs/form-vue/compare/v0.0.3...v0.0.4 153 | [0.0.3]: https://github.com/volverjs/form-vue/compare/v0.0.2...v0.0.3 154 | [0.0.2]: https://github.com/volverjs/form-vue/compare/v0.0.1...v0.0.2 155 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | This application uses Open Source components. You can find the source code of their open source projects along with license information below. We acknowledge and are grateful to these developers for their contributions to open source. 3 | 4 | ------------------------------------------------------------------------------- 5 | 6 | ## Project 7 | @volverjs/ui-vue 8 | 9 | ### Source 10 | https://github.com/volverjs/ui-vue 11 | 12 | ### License 13 | MIT License 14 | 15 | Copyright (c) 2022-present 8 wave S.r.l. and contributors 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | 35 | 36 | ------------------------------------------------------------------------------- 37 | 38 | ## Project 39 | @vueuse/core 40 | 41 | ### Source 42 | https://github.com/vueuse/vueuse 43 | 44 | ### License 45 | MIT License 46 | 47 | Copyright (c) 2019-PRESENT Anthony Fu 48 | 49 | Permission is hereby granted, free of charge, to any person obtaining a copy 50 | of this software and associated documentation files (the "Software"), to deal 51 | in the Software without restriction, including without limitation the rights 52 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 53 | copies of the Software, and to permit persons to whom the Software is 54 | furnished to do so, subject to the following conditions: 55 | 56 | The above copyright notice and this permission notice shall be included in all 57 | copies or substantial portions of the Software. 58 | 59 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 60 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 61 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 62 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 63 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 64 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 65 | SOFTWARE. 66 | 67 | 68 | ------------------------------------------------------------------------------- 69 | 70 | ## Project 71 | ts-dot-prop 72 | 73 | ### Source 74 | https://github.com/justinlettau/ts-dot-prop 75 | 76 | ### License 77 | MIT License 78 | 79 | Copyright (c) Justin Lettau 80 | 81 | Permission is hereby granted, free of charge, to any person obtaining a copy 82 | of this software and associated documentation files (the "Software"), to deal 83 | in the Software without restriction, including without limitation the rights 84 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 85 | copies of the Software, and to permit persons to whom the Software is 86 | furnished to do so, subject to the following conditions: 87 | 88 | The above copyright notice and this permission notice shall be included in all 89 | copies or substantial portions of the Software. 90 | 91 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 92 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 93 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 94 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 95 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 96 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 97 | SOFTWARE. 98 | 99 | 100 | ------------------------------------------------------------------------------- 101 | 102 | ## Project 103 | vue 104 | 105 | ### Source 106 | https://github.com/vuejs/core 107 | 108 | ### License 109 | The MIT License (MIT) 110 | 111 | Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors 112 | 113 | Permission is hereby granted, free of charge, to any person obtaining a copy 114 | of this software and associated documentation files (the "Software"), to deal 115 | in the Software without restriction, including without limitation the rights 116 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 117 | copies of the Software, and to permit persons to whom the Software is 118 | furnished to do so, subject to the following conditions: 119 | 120 | The above copyright notice and this permission notice shall be included in 121 | all copies or substantial portions of the Software. 122 | 123 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 124 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 125 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 126 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 127 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 128 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 129 | THE SOFTWARE. 130 | 131 | 132 | ------------------------------------------------------------------------------- 133 | 134 | ## Project 135 | zod 136 | 137 | ### Source 138 | https://github.com/colinhacks/zod 139 | 140 | ### License 141 | MIT License 142 | 143 | Copyright (c) 2020 Colin McDonnell 144 | 145 | Permission is hereby granted, free of charge, to any person obtaining a copy 146 | of this software and associated documentation files (the "Software"), to deal 147 | in the Software without restriction, including without limitation the rights 148 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 149 | copies of the Software, and to permit persons to whom the Software is 150 | furnished to do so, subject to the following conditions: 151 | 152 | The above copyright notice and this permission notice shall be included in all 153 | copies or substantial portions of the Software. 154 | 155 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 156 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 157 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 158 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 159 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 160 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 161 | SOFTWARE. 162 | 163 | 164 | ------------------------------------------------------------------------------- 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present 8 wave S.r.l. and contributors 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 | 3 | [![volverjs](docs/static/volverjs-form.svg)](https://volverjs.github.io/form-vue) 4 | 5 | ## @volverjs/form-vue 6 | 7 | `form` `form-field` `form-wrapper` `vue3` `zod` `validation` 8 | 9 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=volverjs_form-vue&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=volverjs_form-vue) [![Depfu](https://badges.depfu.com/badges/e2c464e3cb95f98ee6a9a566dd44e0a9/status.svg)](https://depfu.com) [![Depfu](https://badges.depfu.com/badges/e2c464e3cb95f98ee6a9a566dd44e0a9/overview.svg)](https://depfu.com/github/volverjs/form-vue?project_id=38569) 10 | 11 |
12 | 13 | maintained with ❤️ by 14 | 15 |
16 | 17 | [![8 Wave](docs/static/8wave.svg)](https://8wave.it) 18 | 19 |
20 | 21 |
22 | 23 | ## Install 24 | 25 | ```bash 26 | # pnpm 27 | pnpm add @volverjs/form-vue 28 | 29 | # yarn 30 | yarn add @volverjs/form-vue 31 | 32 | # npm 33 | npm install @volverjs/form-vue --save 34 | ``` 35 | 36 | ## Usage 37 | 38 | `@volverjs/form-vue` allow you to create a Vue 3 form with [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) components from a [Zod Object](https://zod.dev/?id=objects) schema. It provides two functions: `createForm()` and `useForm()`. 39 | 40 | ## Plugin 41 | 42 | `createForm()` defines globally three components `VvForm`, `VvFormWrapper`, and `VvFormField` through a [Vue 3 Plugin](https://vuejs.org/guide/reusability/plugins.html). 43 | 44 | ```typescript 45 | import { createApp } from 'vue' 46 | import { createForm } from '@volverjs/form-vue' 47 | import { z } from 'zod' 48 | 49 | const schema = z.object({ 50 | firstName: z.string(), 51 | lastName: z.string() 52 | }) 53 | 54 | const app = createApp(App) 55 | const form = createForm({ 56 | schema 57 | // lazyLoad: boolean - default false 58 | // updateThrottle: number - default 500 59 | // continuousValidation: boolean - default false 60 | // sideEffects?: (data: any) => void 61 | // scope?: string - Defines a unique scope for the form instance (singletons) 62 | // class?: new (data?: any) => Type - Type constructor for form data 63 | // Example: 64 | // class: class User { constructor(data?: any) { Object.assign(this, data) } } 65 | }) 66 | 67 | app.use(form) 68 | app.mount('#app') 69 | ``` 70 | 71 | If the schema is omitted, the plugin only share the options to the forms created with the [composable](https://github.com/volverjs/form-vue/#composable). 72 | 73 | ### VvForm 74 | 75 | `VvForm` render a `form` tag and emit a `submit` event. Form data are validated on submit. 76 | A `valid` or `invalid` event is emitted when the form status changes. 77 | 78 | ```vue 79 | 87 | 88 | 96 | ``` 97 | 98 | The submit can be triggered programmatically with the `submit()` method. 99 | 100 | ```vue 101 | 113 | 114 | 122 | ``` 123 | 124 | Use the `v-model` directive (or only `:model-value` to set the initial value of form data) or bind the form data. 125 | 126 | The form data two way binding is **throttled** by default (500ms) to avoid performance issues. The throttle can be changed with the `updateThrottle` option or prop. 127 | 128 | By default form validation **stops** when a **valid state** is reached. 129 | To activate **continuous validation** use the `continuousValidation` option or prop. 130 | 131 | ```vue 132 | 140 | 141 | 146 | ``` 147 | 148 | ## Composable 149 | 150 | `useForm()` can be used to create a form programmatically inside a Vue 3 Component. 151 | The **default settings** are **inherited** from the plugin (if it's installed). 152 | 153 | ```vue 154 | 172 | 173 | 179 | ``` 180 | 181 | ### Outside a Vue 3 Component 182 | 183 | `useForm()` can create a form also outside a Vue 3 Component, plugin settings are **not inherited**. 184 | 185 | ```ts 186 | import { useForm } from '@volverjs/form-vue' 187 | import { z } from 'zod' 188 | 189 | const schema = z.object({ 190 | firstName: z.string(), 191 | lastName: z.string() 192 | }) 193 | 194 | const { 195 | VvForm, 196 | VvFormWrapper, 197 | VvFormField, 198 | VvFormFieldsGroup, 199 | VvFormTemplate, 200 | formData, 201 | status, 202 | errors, 203 | wrappers 204 | } = useForm(schema, { 205 | lazyLoad: true 206 | }) 207 | 208 | export default { 209 | VvForm, 210 | VvFormWrapper, 211 | VvFormField, 212 | VvFormFieldsGroup, 213 | VvFormTemplate, 214 | formData, 215 | status, 216 | errors, 217 | wrappers 218 | } 219 | ``` 220 | 221 | ### VvFormWrapper 222 | 223 | `VvFormWrapper` gives you the validation status of a part of your form. 224 | The wrapper status is invalid if at least one of the fields inside it is invalid. 225 | 226 | ```vue 227 | 243 | ``` 244 | 245 | `VvFormWrapper` can be used recursively to create a validation tree. The wrapper status is invalid if **at least one of the fields** inside it or one of its children **is invalid**. 246 | 247 | ```vue 248 | 266 | ``` 267 | 268 | The `wrappers` map provides access to form wrapper data. 269 | This allows for better control over form validation state and data management. 270 | 271 | ```vue 272 | 278 | ``` 279 | 280 | ### VvFormField 281 | 282 | `VvFormField` allow you to render a form field or a [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) input component inside a form. 283 | 284 | It automatically bind the form data through the `name` attribute. For nested objects, use the `name` attribute with **dot notation**. 285 | 286 | ```vue 287 | 309 | ``` 310 | 311 | To render a [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) input component, use the `type` attribute. 312 | By default UI components must be installed globally, they can be lazy-loaded with `lazyLoad` option or prop. 313 | 314 | ```vue 315 | 321 | ``` 322 | 323 | Check the [`VvFormField` documentation](./docs/VvFormField.md) to learn more about form fields. 324 | 325 | ### VvFormFieldsGroup 326 | 327 | `VvFormFieldsGroup` allow you to render a group of form fields inside a form. 328 | 329 | It automatically bind the form data through the `names` attribute. For nested objects, use the `names` attribute with **dot notation**. 330 | 331 | ```vue 332 | 373 | ``` 374 | 375 | Alternatively, you can create a custom component to render the group of form fields. 376 | 377 | ```vue 378 | // MyFieldsGroup.vue 379 | 389 | 390 | 422 | ``` 423 | 424 | An than use it inside the `VvFormFieldsGroup` with the `:is` attribute. 425 | 426 | ```vue 427 | 430 | 431 | 439 | ``` 440 | 441 | You can also map the form fields to the components v-models. The `:names` attribute can be an object with the component v-models as keys and the form fields names as values. 442 | 443 | ```vue 444 | 447 | 448 | 458 | ``` 459 | 460 | ## VvFormTemplate 461 | 462 | Forms can also be created using a template. A template is an **array of objects** that describes the form fields. All properties that are **not listed** below are passed to the component **as props**. 463 | 464 | ```vue 465 | 523 | 524 | 529 | ``` 530 | 531 | Template items, by default, are rendered as a `VvFormField` component but this can be changed using the `vvIs` property. The `vvIs` property can be a string or a component. 532 | 533 | `vvName` refers to the name of the field in the schema and can be a nested property using **dot notation**. 534 | `vvType` refers to the type of the field and can be any of the supported [types](./docs/VvFormField.md#ui-components). 535 | `vvDefaultValue` can be used to set default values for the form item. 536 | `vvShowValid` can be used to show the valid state of the form item. 537 | `vvSlots` can be used to pass a slots to the template item. 538 | `vvChildren` is an array of template items which will be wrapped in the parent item. 539 | 540 | Conditional rendering can be achieved using the `vvIf` and `vvElseIf` properties. 541 | 542 | ```vue 543 | 602 | 603 | 608 | ``` 609 | 610 | `vvElseIf` can be used multiple times. `vvElseIf: true` is like an `else` statement and will be rendered if all previous `vvIf` and `vvElseIf` conditions are false. 611 | 612 | `vvIf` and `vvElseIf` can be a string or a function. If it is a string it will be evaluated as a **property** of the form data. If it is a function it will be called with the **form context** as the **first argument** and must return a boolean. 613 | 614 | ```ts 615 | const templateSchema = [ 616 | { 617 | vvIf: ctx => ctx.formData.value.hasUsername, 618 | vvName: 'username', 619 | vvType: 'text', 620 | label: 'Username' 621 | } 622 | ] 623 | ``` 624 | 625 | Also the template schema and all template items can be a function. 626 | The function will be called with the **form context** as the **first argument**. 627 | 628 | ```ts 629 | function templateSchema(ctx) { 630 | return [ 631 | { 632 | vvName: 'firstName', 633 | vvType: 'text', 634 | label: `Hi ${ctx.formData.value.firstName}!` 635 | } 636 | ] 637 | } 638 | ``` 639 | 640 | ```ts 641 | const templateSchema = [ 642 | ctx => ({ 643 | vvName: 'firstName', 644 | vvType: 'text', 645 | label: `Hi ${ctx.formData.value.firstName}!` 646 | }), 647 | { 648 | vvName: 'username', 649 | type: 'text', 650 | label: 'username' 651 | } 652 | ] 653 | ``` 654 | 655 | ## Default Object by Zod Object Schema 656 | 657 | `defaultObjectBySchema` creates an object by a [Zod Object Schema](https://zod.dev/?id=objects). 658 | It can be useful to create a **default object** for a **form**. The default object is created by the default values of the schema and can be merged with an other object passed as parameter. 659 | 660 | ```ts 661 | import { z } from 'zod' 662 | import { defaultObjectBySchema } from '@volverjs/form-vue' 663 | 664 | const schema = z.object({ 665 | firstName: z.string().default('John'), 666 | lastName: z.string().default('Doe') 667 | }) 668 | 669 | const defaultObject = defaultObjectBySchema(schema) 670 | // defaultObject = { firstName: 'John', lastName: 'Doe' } 671 | 672 | const defaultObject = defaultObjectBySchema(schema, { name: 'Jane' }) 673 | // defaultObject = { firstName: 'Jane', lastName: 'Doe' } 674 | ``` 675 | 676 | `defaultObjectBySchema` can be used with nested objects. 677 | 678 | ```ts 679 | import { z } from 'zod' 680 | import { defaultObjectBySchema } from '@volverjs/form-vue' 681 | 682 | const schema = z.object({ 683 | firstName: z.string().default('John'), 684 | lastName: z.string().default('Doe'), 685 | address: z.object({ 686 | street: z.string().default('Main Street'), 687 | number: z.number().default(1) 688 | }) 689 | }) 690 | 691 | const defaultObject = defaultObjectBySchema(schema) 692 | // defaultObject = { firstName: 'John', lastName: 'Doe', address: { street: 'Main Street', number: 1 } } 693 | ``` 694 | 695 | Other Zod methods are also supported: [`z.nullable()`](https://github.com/colinhacks/zod#nullable), [`z.coerce`](https://github.com/colinhacks/zod#coercion-for-primitives) and [`z.passthrough()`](https://github.com/colinhacks/zod#passthrough). 696 | 697 | ```ts 698 | import { z } from 'zod' 699 | import { defaultObjectBySchema } from '@volverjs/form-vue' 700 | 701 | const schema = z 702 | .object({ 703 | firstName: z.string().default('John'), 704 | lastName: z.string().default('Doe'), 705 | address: z.object({ 706 | street: z.string().default('Main Street'), 707 | number: z.number().default(1) 708 | }), 709 | age: z.number().nullable().default(null), 710 | height: z.coerce.number().default(1.8), 711 | weight: z.number().default(80) 712 | }) 713 | .passthrough() 714 | 715 | const defaultObject = defaultObjectBySchema(schema, { 716 | height: '1.9', 717 | email: 'john.doe@test.com' 718 | }) 719 | // defaultObject = { firstName: 'John', lastName: 'Doe', address: { street: 'Main Street', number: 1 }, age: null, height: 1.9, weight: 80, email: 'john.doe@test.com' } 720 | ``` 721 | 722 | ## License 723 | 724 | [MIT](http://opensource.org/licenses/MIT) 725 | -------------------------------------------------------------------------------- /docs/VvForm.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volverjs/form-vue/0eabcd22e322ecbebe1f7b1a993c87a9b6f739e7/docs/VvForm.md -------------------------------------------------------------------------------- /docs/VvFormField.md: -------------------------------------------------------------------------------- 1 | # VvFormField 2 | 3 | `VvFormField` allow you to render a form field or a [`@volverjs/ui-vue`](https://github.com/volverjs/ui-vue) input component inside a form. 4 | 5 | ## Template 6 | 7 | By default, `VvFormField` renders templates passed through the default slot. 8 | 9 | ```vue 10 | 20 | 21 | 59 | ``` 60 | 61 | ## Custom Components 62 | 63 | Field templates can be rendered using custom components. 64 | 65 | ```vue 66 | 96 | 97 | 112 | ``` 113 | 114 | Using the `is` prop. 115 | 116 | ```vue 117 | 128 | 129 | 134 | ``` 135 | 136 | ## UI Components 137 | 138 | ### `VvInputText` 139 | 140 | The following types are rendered as [`VvInputText`](https://volverjs.github.io/ui-vue/?path=/docs/components-inputtext--docs): 141 | 142 | - `text`; 143 | - `number`; 144 | - `email`; 145 | - `password`; 146 | - `tel`; 147 | - `url`; 148 | - `search`; 149 | - `date`; 150 | - `time`; 151 | - `datetime-local`; 152 | - `month`; 153 | - `week`; 154 | - `color`. 155 | 156 | ### `VvSelect` 157 | 158 | The `select` type is rendered as [`VvSelect`](https://volverjs.github.io/ui-vue/?path=/docs/components-select--docs). 159 | 160 | ### `VvCheckbox` 161 | 162 | The `checkbox` type is rendered as [`VvCheckbox`](https://volverjs.github.io/ui-vue/?path=/docs/components-checkbox--docs). 163 | 164 | ### `VvCheckboxGroup` 165 | 166 | The `checkboxGroup` type is rendered as [`VvCheckboxGroup`](https://volverjs.github.io/ui-vue/?path=/docs/components-checkboxgroup--docs). 167 | 168 | ### `VvRadio` 169 | 170 | The `radio` type is rendered as [`VvRadio`](https://volverjs.github.io/ui-vue/?path=/docs/components-radio--docs). 171 | 172 | ### `VvRadioGroup` 173 | 174 | The `radioGroup` type is rendered as [`VvRadioGroup`](https://volverjs.github.io/ui-vue/?path=/docs/components-radiogroup--docs). 175 | 176 | ### `VvTextarea` 177 | 178 | The `textarea` type is rendered as [`VvTextarea`](https://volverjs.github.io/ui-vue/?path=/docs/components-textarea--docs). 179 | 180 | ### `VvCombobox` 181 | 182 | The `combobox` type is rendered as [`VvCombobox`](https://volverjs.github.io/ui-vue/?path=/docs/components-combobox--docs). 183 | 184 | ## Events 185 | 186 | `VvFormField` emits the following events: `invalid`, `valid` and `update:modelValue`. 187 | 188 | ```vue 189 | 199 | ``` 200 | 201 | ## Nested VvFormField 202 | 203 | `VvFormField` can also be nested to handle some kind of group validation. 204 | 205 | ```vue 206 | 235 | 236 | 254 | ``` 255 | -------------------------------------------------------------------------------- /docs/VvFormTemplate.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volverjs/form-vue/0eabcd22e322ecbebe1f7b1a993c87a9b6f739e7/docs/VvFormTemplate.md -------------------------------------------------------------------------------- /docs/VvFormWrapeer.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volverjs/form-vue/0eabcd22e322ecbebe1f7b1a993c87a9b6f739e7/docs/VvFormWrapeer.md -------------------------------------------------------------------------------- /docs/static/8wave.svg: -------------------------------------------------------------------------------- 1 | 8 | 19 | 23 | 26 | 29 | 32 | 35 | 38 | 42 | 45 | 48 | 51 | 52 | -------------------------------------------------------------------------------- /docs/static/volverjs-form.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 19 | 21 | 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | typescript: { 5 | overrides: { 6 | 'ts/explicit-function-return-type': 'off', 7 | 'ts/consistent-type-definitions': 'off', 8 | }, 9 | }, 10 | vue: true, 11 | node: true, 12 | yaml: false, 13 | stylistic: { 14 | indent: 4, 15 | quotes: 'single', 16 | semi: false, 17 | }, 18 | rules: { 19 | 'sort-imports': 'off', 20 | 'perfectionist/sort-imports': 'off', 21 | 'perfectionist/sort-named-imports': 'off', 22 | 'antfu/top-level-function': 'off', 23 | }, 24 | }, { 25 | ignores: ['.vscode', 'dist', 'node_modules', '*.config.ts', '**/*.test.ts'], 26 | }) 27 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jest-environment-node', 4 | testMatch: ['**/test/**/*.ts', '**/test/**/*.js'], 5 | verbose: true, 6 | testPathIgnorePatterns: ['dist.*\\.ts$'], 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@volverjs/form-vue", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "packageManager": "pnpm@10.8.0", 6 | "description": "Vue 3 Forms with @volverjs/ui-vue", 7 | "author": "8 Wave S.r.l.", 8 | "license": "MIT", 9 | "homepage": "https://github.com/volverjs/form-vue", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/volverjs/form-vue" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/volverjs/form-vue/issues" 16 | }, 17 | "keywords": [ 18 | "form", 19 | "form-field", 20 | "form-wrapper", 21 | "vue3", 22 | "zod", 23 | "validation" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "import": "./dist/index.es.js", 29 | "default": "./dist/index.umd.js" 30 | }, 31 | "./src/*": "./src/*", 32 | "./dist/*": "./dist/*" 33 | }, 34 | "main": "./dist/index.js", 35 | "module": "./dist/index.js", 36 | "types": "./dist/index.d.ts", 37 | "typesVersions": { 38 | "*": { 39 | "*": [ 40 | "dist/index.d.ts" 41 | ] 42 | } 43 | }, 44 | "files": [ 45 | "*.d.ts", 46 | "dist", 47 | "node", 48 | "src" 49 | ], 50 | "engines": { 51 | "node": ">= 16.x" 52 | }, 53 | "scripts": { 54 | "lint": "eslint .", 55 | "lint:fix": "eslint . --fix", 56 | "type-check": "tsc --noEmit", 57 | "dev": "vite build --watch", 58 | "build": "vite build", 59 | "test": "pnpm run build && pnpm run test-vitest && pnpm run test-playwright", 60 | "test-vitest": "vitest run", 61 | "test-vitest-watch": "vitest", 62 | "test-playwright": "playwright test -c playwright-ct.config.ts", 63 | "test-playwright:ui": "playwright test -c playwright-ct.config.ts --ui", 64 | "credits": "npx @opengovsg/credits-generator" 65 | }, 66 | "peerDependencies": { 67 | "@volverjs/ui-vue": "^0.0.9", 68 | "@vueuse/core": "^13.0.0", 69 | "ts-dot-prop": "^2.1.4", 70 | "vue": "^3.5.13", 71 | "zod": "^3.24.2" 72 | }, 73 | "devDependencies": { 74 | "@antfu/eslint-config": "^4.11.0", 75 | "@nabla/vite-plugin-eslint": "^2.0.5", 76 | "@playwright/experimental-ct-vue": "^1.51.1", 77 | "@testing-library/vue": "^8.1.0", 78 | "@vitejs/plugin-vue": "^5.2.3", 79 | "@volverjs/style": "^0.1.17", 80 | "@volverjs/ui-vue": "^0.0.9", 81 | "@vue/compiler-sfc": "^3.5.13", 82 | "@vue/runtime-core": "^3.5.13", 83 | "@vue/test-utils": "^2.4.6", 84 | "@vueuse/core": "^13.1.0", 85 | "copy": "^0.3.2", 86 | "eslint": "^9.24.0", 87 | "happy-dom": "^17.4.4", 88 | "ts-dot-prop": "^2.1.4", 89 | "typescript": "^5.8.3", 90 | "vite": "^6.2.5", 91 | "vite-plugin-dts": "^4.5.3", 92 | "vite-plugin-externalize-deps": "^0.9.0", 93 | "vitest": "^3.1.1", 94 | "vue": "^3.5.13", 95 | "zod": "^3.24.2" 96 | }, 97 | "pnpm": { 98 | "onlyBuiltDependencies": [ 99 | "esbuild", 100 | "vue-demi" 101 | ] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /playwright-ct.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/experimental-ct-vue' 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './test-playwright', 8 | /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ 9 | snapshotDir: './__snapshots__', 10 | /* Maximum time one test can run for. */ 11 | timeout: 10 * 1000, 12 | /* Run tests in files in parallel */ 13 | fullyParallel: true, 14 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 15 | forbidOnly: !!process.env.CI, 16 | /* Retry on CI only */ 17 | retries: process.env.CI ? 2 : 0, 18 | /* Opt out of parallel tests on CI. */ 19 | workers: process.env.CI ? 1 : undefined, 20 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 21 | reporter: 'html', 22 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 23 | use: { 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: 'on-first-retry', 26 | 27 | /* Port to use for Playwright component endpoint. */ 28 | ctPort: 3100, 29 | }, 30 | 31 | /* Configure projects for major browsers */ 32 | projects: [ 33 | { 34 | name: 'chromium', 35 | use: { ...devices['Desktop Chrome'] }, 36 | }, 37 | { 38 | name: 'firefox', 39 | use: { ...devices['Desktop Firefox'] }, 40 | }, 41 | { 42 | name: 'webkit', 43 | use: { ...devices['Desktop Safari'] }, 44 | }, 45 | ], 46 | }) 47 | -------------------------------------------------------------------------------- /playwright/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Testing Page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /playwright/index.ts: -------------------------------------------------------------------------------- 1 | // Import styles, initialize component theme here. 2 | // import '../src/common.css'; 3 | import '@volverjs/style' 4 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=volverjs_form-vue 2 | sonar.organization=volverjs 3 | sonar.coverage.exclusions=test-vitest/**,test-playwright/**,playwright/** -------------------------------------------------------------------------------- /src/VvForm.ts: -------------------------------------------------------------------------------- 1 | import type { Component, InjectionKey, PropType, SlotsType, UnwrapRef } from 'vue' 2 | import type { RefinementCtx, z } from 'zod' 3 | import type { 4 | FormComponentOptions, 5 | FormSchema, 6 | FormTemplate, 7 | InjectedFormData, 8 | InjectedFormWrapperData, 9 | Path, 10 | } from './types' 11 | import { 12 | computed, 13 | defineComponent, 14 | h, 15 | isProxy, 16 | onMounted, 17 | provide, 18 | readonly as makeReadonly, 19 | ref, 20 | toRaw, 21 | watch, 22 | withModifiers, 23 | } from 'vue' 24 | import { 25 | throttleFilter, 26 | watchIgnorable, 27 | } from '@vueuse/core' 28 | import { ZodError } from 'zod' 29 | import { FormStatus } from './enums' 30 | import { defaultObjectBySchema } from './utils' 31 | 32 | export function defineForm(schema: Schema, provideKey: InjectionKey>, options: FormComponentOptions, VvFormTemplate: FormTemplateComponent, wrappers: Map>) { 33 | const errors = ref | undefined>() 34 | const status = ref() 35 | const invalid = computed(() => status.value === FormStatus.invalid) 36 | const formData = ref> : Type>() 37 | const readonly = ref(false) 38 | let validateFields: Set>> | undefined 39 | 40 | const formDataAdapter = (data?: z.infer): undefined extends Type ? Partial> : Type => { 41 | const toReturn = defaultObjectBySchema(schema, data) 42 | if (options?.class) { 43 | const ClassObject = options.class 44 | // @ts-expect-error - this is a class 45 | return new ClassObject(toReturn) 46 | } 47 | // @ts-expect-error - this is a plain object 48 | return toReturn 49 | } 50 | 51 | const validate = async (value = formData.value, options?: { 52 | fields?: Set>> 53 | superRefine?: (arg: z.infer, ctx: RefinementCtx) => void | Promise 54 | }) => { 55 | validateFields = options?.fields 56 | if (readonly.value) { 57 | return true 58 | } 59 | const parseResult = options?.superRefine 60 | ? await schema.superRefine(options.superRefine).safeParseAsync(value) 61 | : await schema.safeParseAsync(value) 62 | if (!parseResult.success) { 63 | status.value = FormStatus.invalid 64 | if (!validateFields?.size) { 65 | errors.value = parseResult.error.format() as z.inferFormattedError 66 | return false 67 | } 68 | const fieldsIssues = parseResult.error.issues.filter(item => 69 | validateFields?.has(item.path.join('.') as Path>), 70 | ) 71 | if (!fieldsIssues.length) { 72 | errors.value = undefined 73 | return true 74 | } 75 | errors.value = new ZodError(fieldsIssues).format() as z.inferFormattedError 76 | return false 77 | } 78 | errors.value = undefined 79 | status.value = FormStatus.valid 80 | formData.value = formDataAdapter(parseResult.data) 81 | return true 82 | } 83 | 84 | const clear = () => { 85 | errors.value = undefined 86 | status.value = undefined 87 | validateFields = undefined 88 | } 89 | 90 | const reset = () => { 91 | formData.value = formDataAdapter() 92 | clear() 93 | status.value = FormStatus.reset 94 | } 95 | 96 | const submit = async (options?: { 97 | fields?: Set>> 98 | superRefine?: (arg: z.infer, ctx: RefinementCtx) => void | Promise 99 | }) => { 100 | if (readonly.value) { 101 | return false 102 | } 103 | if (!(await validate(undefined, options))) { 104 | return false 105 | } 106 | status.value = FormStatus.submitting 107 | return true 108 | } 109 | 110 | const { ignoreUpdates, stop: stopUpdatesWatch } = watchIgnorable( 111 | formData, 112 | () => { 113 | status.value = FormStatus.updated 114 | }, 115 | { 116 | deep: true, 117 | eventFilter: throttleFilter(options?.updateThrottle ?? 500), 118 | }, 119 | ) 120 | 121 | const readonlyErrors = makeReadonly(errors) 122 | const readonlyStatus = makeReadonly(status) 123 | 124 | const VvForm = defineComponent({ 125 | name: 'VvForm', 126 | props: { 127 | continuousValidation: { 128 | type: Boolean, 129 | default: false, 130 | }, 131 | modelValue: { 132 | type: Object, 133 | default: () => ({}), 134 | }, 135 | readonly: { 136 | type: Boolean, 137 | default: options?.readonly ?? false, 138 | }, 139 | tag: { 140 | type: String, 141 | default: 'form', 142 | }, 143 | template: { 144 | type: [Array, Function] as PropType>, 145 | default: undefined, 146 | }, 147 | superRefine: { 148 | type: Function as PropType<(arg: z.infer, ctx: RefinementCtx) => void | Promise>, 149 | default: undefined, 150 | }, 151 | validateFields: { 152 | type: Array as PropType>[]>, 153 | default: undefined, 154 | }, 155 | }, 156 | emits: [ 157 | 'invalid', 158 | 'submit', 159 | 'update:modelValue', 160 | 'update:readonly', 161 | 'valid', 162 | 'reset', 163 | ], 164 | expose: [ 165 | 'errors', 166 | 'invalid', 167 | 'readonly', 168 | 'status', 169 | 'submit', 170 | 'tag', 171 | 'template', 172 | 'valid', 173 | 'validate', 174 | 'clear', 175 | 'reset', 176 | ], 177 | slots: Object as SlotsType<{ 178 | default: { 179 | errors: UnwrapRef 180 | formData: UnwrapRef 181 | invalid: UnwrapRef 182 | readonly: UnwrapRef 183 | status: UnwrapRef 184 | wrappers: typeof wrappers 185 | clear: typeof clear 186 | ignoreUpdates: typeof ignoreUpdates 187 | reset: typeof reset 188 | stopUpdatesWatch: typeof stopUpdatesWatch 189 | submit: typeof submit 190 | validate: typeof validate 191 | } 192 | }>, 193 | setup(props, { emit }) { 194 | formData.value = formDataAdapter(toRaw(props.modelValue)) 195 | 196 | watch( 197 | () => props.modelValue, 198 | (newValue) => { 199 | if (newValue) { 200 | const original = isProxy(newValue) 201 | ? toRaw(newValue) 202 | : newValue 203 | 204 | if ( 205 | JSON.stringify(original) 206 | === JSON.stringify(toRaw(formData.value)) 207 | ) { 208 | return 209 | } 210 | 211 | formData.value = typeof original?.clone === 'function' 212 | ? original.clone() 213 | : JSON.parse(JSON.stringify(original)) 214 | } 215 | }, 216 | { deep: true }, 217 | ) 218 | 219 | watch(status, async (newValue) => { 220 | if (newValue === FormStatus.invalid) { 221 | const toReturn = toRaw(errors.value) 222 | emit('invalid', toReturn) 223 | options?.onInvalid?.( 224 | toReturn as z.inferFormattedError | undefined, 225 | ) 226 | return 227 | } 228 | if (newValue === FormStatus.valid) { 229 | const toReturn = toRaw(formData.value) 230 | emit('valid', toReturn) 231 | options?.onValid?.(toReturn) 232 | emit('update:modelValue', toReturn) 233 | options?.onUpdate?.(toReturn) 234 | return 235 | } 236 | if (newValue === FormStatus.submitting) { 237 | const toReturn = toRaw(formData.value) 238 | emit('submit', toReturn) 239 | options?.onSubmit?.(toReturn) 240 | return 241 | } 242 | if (newValue === FormStatus.reset) { 243 | const toReturn = toRaw(formData.value) 244 | emit('reset', toReturn) 245 | options?.onReset?.(toReturn) 246 | return 247 | } 248 | if (newValue === FormStatus.updated) { 249 | if ( 250 | errors.value 251 | || options?.continuousValidation 252 | || props.continuousValidation 253 | ) { 254 | await validate(undefined, { 255 | superRefine: props.superRefine, 256 | fields: validateFields ?? new Set(props.validateFields), 257 | }) 258 | } 259 | if ( 260 | !formData.value 261 | || !props.modelValue 262 | || JSON.stringify(formData.value) !== JSON.stringify(props.modelValue) 263 | ) { 264 | const toReturn = toRaw(formData.value) 265 | emit('update:modelValue', toReturn) 266 | options?.onUpdate?.(toReturn) 267 | } 268 | if (status.value === FormStatus.updated) { 269 | status.value = FormStatus.unknown 270 | } 271 | } 272 | }) 273 | 274 | // readonly 275 | onMounted(() => { 276 | readonly.value = props.readonly 277 | }) 278 | watch( 279 | () => props.readonly, 280 | (newValue) => { 281 | readonly.value = newValue 282 | }, 283 | ) 284 | watch(readonly, (newValue) => { 285 | if (newValue !== props.readonly) { 286 | emit('update:readonly', readonly.value) 287 | } 288 | }) 289 | 290 | provide(provideKey, { 291 | clear, 292 | errors: readonlyErrors, 293 | formData, 294 | ignoreUpdates, 295 | invalid, 296 | readonly, 297 | reset, 298 | status: readonlyStatus, 299 | stopUpdatesWatch, 300 | submit, 301 | validate, 302 | wrappers, 303 | }) 304 | 305 | return { 306 | clear, 307 | errors: readonlyErrors, 308 | formData, 309 | ignoreUpdates, 310 | invalid, 311 | isReadonly: readonly, 312 | reset, 313 | status: readonlyStatus, 314 | stopUpdatesWatch, 315 | submit: () => submit({ 316 | superRefine: props.superRefine, 317 | fields: new Set(props.validateFields), 318 | }), 319 | validate, 320 | wrappers, 321 | } 322 | }, 323 | render() { 324 | const defaultSlot = () => 325 | this.$slots?.default?.({ 326 | errors: readonlyErrors.value, 327 | formData: formData.value, 328 | invalid: invalid.value, 329 | readonly: readonly.value, 330 | status: readonlyStatus.value, 331 | wrappers, 332 | clear, 333 | ignoreUpdates, 334 | reset, 335 | stopUpdatesWatch, 336 | submit, 337 | validate, 338 | }) ?? this.$slots.default 339 | return h( 340 | this.tag, 341 | { 342 | onSubmit: withModifiers(this.submit, ['prevent']), 343 | onReset: withModifiers(this.reset, ['prevent']), 344 | }, 345 | (this.template ?? options?.template) && VvFormTemplate 346 | ? [ 347 | h( 348 | VvFormTemplate, 349 | { 350 | schema: this.template ?? options?.template, 351 | }, 352 | { 353 | default: defaultSlot, 354 | }, 355 | ), 356 | ] 357 | : { 358 | default: defaultSlot, 359 | }, 360 | ) 361 | }, 362 | }) 363 | return { 364 | clear, 365 | errors, 366 | formData, 367 | ignoreUpdates, 368 | invalid, 369 | readonly, 370 | reset, 371 | status, 372 | wrappers, 373 | stopUpdatesWatch, 374 | submit, 375 | validate, 376 | VvForm, 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /src/VvFormField.ts: -------------------------------------------------------------------------------- 1 | import type { Component, ConcreteComponent, DeepReadonly, InjectionKey, PropType, Ref, SlotsType } from 'vue' 2 | import type { z } from 'zod' 3 | import type { 4 | FormFieldComponentOptions, 5 | FormSchema, 6 | InjectedFormData, 7 | InjectedFormFieldData, 8 | InjectedFormWrapperData, 9 | Path, 10 | } from './types' 11 | import { get, set } from 'ts-dot-prop' 12 | import { 13 | computed, 14 | defineAsyncComponent, 15 | defineComponent, 16 | h, 17 | inject, 18 | onBeforeUnmount, 19 | onMounted, 20 | provide, 21 | readonly, 22 | resolveComponent, 23 | toRefs, 24 | unref, 25 | watch, 26 | useId, 27 | } from 'vue' 28 | import { FormFieldType } from './enums' 29 | 30 | export function defineFormField(formProvideKey: InjectionKey>, wrapperProvideKey: InjectionKey>, formFieldInjectionKey: InjectionKey>, options?: FormFieldComponentOptions) { 31 | return defineComponent({ 32 | name: 'VvFormField', 33 | props: { 34 | type: { 35 | type: String as PropType<`${FormFieldType}`>, 36 | validator: (value: FormFieldType) => { 37 | return Object.values(FormFieldType).includes(value) 38 | }, 39 | default: FormFieldType.custom, 40 | }, 41 | is: { 42 | type: [Object, String] as PropType, 43 | default: undefined, 44 | }, 45 | name: { 46 | type: [String, Number, Boolean, Symbol] as PropType< 47 | Path> 48 | >, 49 | required: true, 50 | }, 51 | props: { 52 | type: [Object, Function] as PropType< 53 | Partial< 54 | | z.infer 55 | | undefined 56 | | (( 57 | formData?: Ref, 58 | ) => Partial> | undefined) 59 | > 60 | >, 61 | default: () => ({}), 62 | }, 63 | showValid: { 64 | type: Boolean, 65 | default: false, 66 | }, 67 | defaultValue: { 68 | type: [String, Number, Boolean, Array, Object], 69 | default: undefined, 70 | }, 71 | lazyLoad: { 72 | type: Boolean, 73 | default: false, 74 | }, 75 | readonly: { 76 | type: Boolean, 77 | default: undefined, 78 | }, 79 | }, 80 | emits: [ 81 | 'invalid', 82 | 'update:formData', 83 | 'update:modelValue', 84 | 'valid', 85 | ], 86 | expose: [ 87 | 'component', 88 | 'errors', 89 | 'hasProps', 90 | 'invalid', 91 | 'invalidLabel', 92 | 'is', 93 | 'type', 94 | ], 95 | slots: Object as SlotsType<{ 96 | [key: string]: any 97 | default: { 98 | errors: DeepReadonly> 99 | formData?: undefined extends Type ? Partial> : Type 100 | formErrors?: DeepReadonly> 101 | invalid: boolean 102 | invalidLabel?: string[] 103 | modelValue: any 104 | readonly: boolean 105 | onUpdate: (value: unknown) => void 106 | submit?: InjectedFormData['submit'] 107 | validate?: InjectedFormData['validate'] 108 | } 109 | }>, 110 | setup(props, { slots, emit }) { 111 | const { props: fieldProps, name: fieldName } = toRefs(props) 112 | const fieldId = useId() 113 | 114 | // inject data from parent form wrapper 115 | const injectedWrapperData = inject(wrapperProvideKey, undefined) 116 | if (injectedWrapperData) { 117 | injectedWrapperData.fields.value.set(fieldId, props.name as string) 118 | } 119 | 120 | // inject data from parent form 121 | const injectedFormData = inject(formProvideKey) 122 | 123 | // v-model 124 | const modelValue = computed({ 125 | get() { 126 | if (!injectedFormData?.formData) { 127 | return 128 | } 129 | return get( 130 | new Object(injectedFormData.formData.value), 131 | String(props.name), 132 | ) 133 | }, 134 | set(value) { 135 | if (!injectedFormData?.formData) { 136 | return 137 | } 138 | set( 139 | new Object(injectedFormData.formData.value), 140 | String(props.name), 141 | value, 142 | ) 143 | emit('update:modelValue', { 144 | newValue: modelValue.value, 145 | formData: injectedFormData?.formData, 146 | }) 147 | }, 148 | }) 149 | onMounted(() => { 150 | if ( 151 | modelValue.value === undefined 152 | && props.defaultValue !== undefined 153 | ) { 154 | modelValue.value = props.defaultValue 155 | } 156 | }) 157 | onBeforeUnmount(() => { 158 | if (injectedWrapperData) { 159 | injectedWrapperData.fields.value.delete(fieldId) 160 | } 161 | }) 162 | 163 | const errors = computed(() => { 164 | if (!injectedFormData?.errors.value) { 165 | return undefined 166 | } 167 | return get(injectedFormData.errors.value, String(props.name)) 168 | }) 169 | const invalidLabel = computed(() => { 170 | return errors.value?._errors 171 | }) 172 | const isInvalid = computed(() => { 173 | return errors.value !== undefined 174 | }) 175 | const unwatchInvalid = watch(isInvalid, (newValue) => { 176 | if (newValue) { 177 | emit('invalid', errors.value) 178 | if (injectedWrapperData) { 179 | injectedWrapperData.errors.value.set( 180 | String(props.name), 181 | errors.value, 182 | ) 183 | } 184 | return 185 | } 186 | emit('valid', modelValue.value) 187 | if (injectedWrapperData) { 188 | injectedWrapperData.errors.value.delete( 189 | props.name as string, 190 | ) 191 | } 192 | }) 193 | const unwatchInjectedFormData = watch( 194 | () => injectedFormData?.formData, 195 | () => { 196 | emit('update:formData', injectedFormData?.formData) 197 | }, 198 | { deep: true }, 199 | ) 200 | onBeforeUnmount(() => { 201 | unwatchInvalid() 202 | unwatchInjectedFormData() 203 | }) 204 | const onUpdate = (value: unknown) => { 205 | if (value instanceof InputEvent) { 206 | value = (value.target as HTMLInputElement).value 207 | } 208 | modelValue.value = value 209 | } 210 | const hasFieldProps = computed(() => { 211 | let toReturn = fieldProps.value 212 | if (typeof toReturn === 'function') { 213 | toReturn = toReturn(injectedFormData?.formData) 214 | } 215 | return Object.keys(toReturn).reduce>( 216 | (acc, key) => { 217 | acc[key] = unref(toReturn[key]) 218 | return acc 219 | }, 220 | {}, 221 | ) 222 | }) 223 | const isReadonly = computed(() => { 224 | if (injectedFormData?.readonly.value) { 225 | return true 226 | } 227 | if (injectedWrapperData?.readonly.value) { 228 | return true 229 | } 230 | return (hasFieldProps.value.readonly ?? props.readonly) as boolean 231 | }) 232 | const hasProps = computed(() => ({ 233 | ...hasFieldProps.value, 234 | 'name': hasFieldProps.value.name ?? props.name, 235 | 'invalid': isInvalid.value, 236 | 'valid': props.showValid 237 | ? Boolean(!isInvalid.value && modelValue.value) 238 | : undefined, 239 | 'type': ((type: FormFieldType) => { 240 | if ( 241 | [ 242 | FormFieldType.color, 243 | FormFieldType.date, 244 | FormFieldType.datetimeLocal, 245 | FormFieldType.email, 246 | FormFieldType.month, 247 | FormFieldType.number, 248 | FormFieldType.password, 249 | FormFieldType.search, 250 | FormFieldType.tel, 251 | FormFieldType.text, 252 | FormFieldType.time, 253 | FormFieldType.url, 254 | FormFieldType.week, 255 | ].includes(type) 256 | ) { 257 | return type 258 | } 259 | return undefined 260 | })(props.type as FormFieldType), 261 | 'invalidLabel': invalidLabel.value, 262 | 'modelValue': modelValue.value, 263 | 'readonly': isReadonly.value, 264 | 'onUpdate:modelValue': onUpdate, 265 | })) 266 | 267 | // provide data to children 268 | provide(formFieldInjectionKey, { 269 | name: readonly(fieldName) as Readonly>>>, 270 | errors: readonly(errors), 271 | }) 272 | 273 | // load component 274 | const component = computed(() => { 275 | if (props.type === FormFieldType.custom) { 276 | return { 277 | render() { 278 | return ( 279 | slots.default?.({ 280 | errors: errors.value, 281 | formData: injectedFormData?.formData.value, 282 | formErrors: injectedFormData?.errors.value, 283 | invalid: isInvalid.value, 284 | invalidLabel: invalidLabel.value, 285 | modelValue: modelValue.value, 286 | readonly: isReadonly.value, 287 | onUpdate, 288 | submit: injectedFormData?.submit, 289 | validate: injectedFormData?.validate, 290 | }) ?? slots.default 291 | ) 292 | }, 293 | } 294 | } 295 | if (!(options?.lazyLoad ?? props.lazyLoad)) { 296 | let component: string | ConcreteComponent 297 | switch (props.type) { 298 | case FormFieldType.select: 299 | component = resolveComponent('VvSelect') 300 | break 301 | case FormFieldType.checkbox: 302 | component = resolveComponent('VvCheckbox') 303 | break 304 | case FormFieldType.radio: 305 | component = resolveComponent('VvRadio') 306 | break 307 | case FormFieldType.textarea: 308 | component = resolveComponent('VvTextarea') 309 | break 310 | case FormFieldType.radioGroup: 311 | component = resolveComponent('VvRadioGroup') 312 | break 313 | case FormFieldType.checkboxGroup: 314 | component = resolveComponent('VvCheckboxGroup') 315 | break 316 | case FormFieldType.combobox: 317 | component = resolveComponent('VvCombobox') 318 | break 319 | default: 320 | component = resolveComponent('VvInputText') 321 | } 322 | if (typeof component !== 'string') { 323 | return component 324 | } 325 | console.warn( 326 | `[@volverjs/form-vue]: ${component} not found, the component will be loaded asynchronously. To avoid this warning, please set "lazyLoad" option.`, 327 | ) 328 | } 329 | return defineAsyncComponent(async () => { 330 | if (options?.sideEffects) { 331 | await Promise.resolve(options.sideEffects(props.type)) 332 | } 333 | switch (props.type) { 334 | case FormFieldType.textarea: 335 | return import( 336 | '@volverjs/ui-vue/vv-textarea' 337 | ) as Component 338 | case FormFieldType.radio: 339 | return import( 340 | '@volverjs/ui-vue/vv-radio' 341 | ) as Component 342 | case FormFieldType.radioGroup: 343 | return import( 344 | '@volverjs/ui-vue/vv-radio-group' 345 | ) as Component 346 | case FormFieldType.checkbox: 347 | return import( 348 | '@volverjs/ui-vue/vv-checkbox' 349 | ) as Component 350 | case FormFieldType.checkboxGroup: 351 | return import( 352 | '@volverjs/ui-vue/vv-checkbox-group' 353 | ) as Component 354 | case FormFieldType.select: 355 | return import( 356 | '@volverjs/ui-vue/vv-select' 357 | ) as Component 358 | case FormFieldType.combobox: 359 | return import( 360 | '@volverjs/ui-vue/vv-combobox' 361 | ) as Component 362 | } 363 | return import('@volverjs/ui-vue/vv-input-text') as Component 364 | }) 365 | }) 366 | 367 | return { component, hasProps, invalid: isInvalid } 368 | }, 369 | render() { 370 | if (this.is) { 371 | return h(this.is, this.hasProps, this.$slots) 372 | } 373 | if (this.type === FormFieldType.custom) { 374 | return h(this.component, null, this.$slots) 375 | } 376 | return h(this.component, this.hasProps, this.$slots) 377 | }, 378 | }) 379 | } 380 | -------------------------------------------------------------------------------- /src/VvFormFieldsGroup.ts: -------------------------------------------------------------------------------- 1 | import type { Component, DeepReadonly, InjectionKey, PropType, Ref, SlotsType } from 'vue' 2 | import type { z } from 'zod' 3 | import type { 4 | FormSchema, 5 | InjectedFormData, 6 | InjectedFormFieldsGroupData, 7 | InjectedFormWrapperData, 8 | Path, 9 | } from './types' 10 | import { get, set } from 'ts-dot-prop' 11 | import { 12 | computed, 13 | defineComponent, 14 | h, 15 | inject, 16 | onBeforeUnmount, 17 | onMounted, 18 | provide, 19 | readonly, 20 | toRefs, 21 | unref, 22 | useId, 23 | watch, 24 | } from 'vue' 25 | 26 | export function defineFormFieldsGroup(formProvideKey: InjectionKey>, wrapperProvideKey: InjectionKey>, formFieldsGroupInjectionKey: InjectionKey>) { 27 | return defineComponent({ 28 | name: 'VvFormFieldsGroup', 29 | props: { 30 | is: { 31 | type: [Object, String] as PropType, 32 | default: undefined, 33 | }, 34 | names: { 35 | type: [Array, Object] as PropType< 36 | Path>[] | Record>> 37 | >, 38 | required: true, 39 | }, 40 | props: { 41 | type: [Object, Function] as PropType< 42 | Partial< 43 | | z.infer 44 | | undefined 45 | | (( 46 | formData?: Ref, 47 | ) => Partial> | undefined) 48 | > 49 | >, 50 | default: () => ({}), 51 | }, 52 | showValid: { 53 | type: Boolean, 54 | default: false, 55 | }, 56 | defaultValues: { 57 | type: [Object] as PropType< 58 | Record>, any> 59 | >, 60 | default: undefined, 61 | }, 62 | readonly: { 63 | type: Boolean, 64 | default: undefined, 65 | }, 66 | }, 67 | emits: [ 68 | 'invalid', 69 | 'update:formData', 70 | 'update:modelValue', 71 | 'valid', 72 | ], 73 | expose: [ 74 | 'component', 75 | 'errors', 76 | 'hasProps', 77 | 'invalid', 78 | 'invalidLabels', 79 | 'is', 80 | ], 81 | slots: Object as SlotsType<{ 82 | [key: string]: any 83 | default: { 84 | errors?: Record>, z.inferFormattedError> 85 | formData?: undefined extends Type ? Partial> : Type 86 | formErrors?: DeepReadonly> 87 | invalid: boolean 88 | invalids: Record 89 | invalidLabels?: Record 90 | modelValue: Record 91 | onUpdate: (value: Record) => void 92 | onUpdateField: (name: string, value: any) => void 93 | readonly: boolean 94 | submit?: InjectedFormData['submit'] 95 | validate?: InjectedFormData['validate'] 96 | } 97 | }>, 98 | setup(props, { slots, emit }) { 99 | const { props: fieldProps, names: fieldsNames, defaultValues } = toRefs(props) 100 | const fieldGroupId = useId() 101 | const names = computed>[]>(() => { 102 | if (Array.isArray(fieldsNames.value)) { 103 | return fieldsNames.value 104 | } 105 | return Object.values(fieldsNames.value) 106 | }) 107 | const namesKeys = computed(() => { 108 | if (Array.isArray(fieldsNames.value)) { 109 | return fieldsNames.value 110 | } 111 | return Object.keys(fieldsNames.value) 112 | }) 113 | const namesMap = computed(() => { 114 | if (Array.isArray(fieldsNames.value)) { 115 | return fieldsNames.value.reduce>>>(( 116 | acc, 117 | name, 118 | ) => { 119 | acc[String(name)] = name 120 | return acc 121 | }, {}) 122 | } 123 | return fieldsNames.value 124 | }) 125 | const namesKeysMap = computed(() => { 126 | return Object.keys(namesMap.value).reduce>((acc, key) => { 127 | acc[String(namesMap.value[key])] = key 128 | return acc 129 | }, {}) 130 | }) 131 | 132 | // inject data from parent form wrapper 133 | const injectedWrapperData = inject(wrapperProvideKey, undefined) 134 | if (injectedWrapperData) { 135 | names.value.forEach((name) => { 136 | injectedWrapperData.fields.value.set(`${fieldGroupId}-${name}`, name as string) 137 | }) 138 | } 139 | 140 | // inject data from parent form 141 | const injectedFormData = inject(formProvideKey) 142 | 143 | // v-model 144 | const modelValue = computed({ 145 | get() { 146 | if (!injectedFormData?.formData) { 147 | return {} 148 | } 149 | return namesKeys.value.reduce>((acc, nameKey) => { 150 | acc[nameKey] = get( 151 | new Object(injectedFormData.formData.value), 152 | namesMap.value[nameKey], 153 | ) 154 | return acc 155 | }, {}) 156 | }, 157 | set(value) { 158 | if (!injectedFormData?.formData) { 159 | return 160 | } 161 | namesKeys.value.forEach((nameKey) => { 162 | set( 163 | new Object(injectedFormData.formData.value), 164 | namesMap.value[nameKey], 165 | value?.[nameKey], 166 | ) 167 | }) 168 | emit('update:modelValue', { 169 | newValue: modelValue.value, 170 | formData: injectedFormData?.formData, 171 | }) 172 | }, 173 | }) 174 | onMounted(() => { 175 | if ( 176 | defaultValues.value 177 | ) { 178 | names.value.forEach((name) => { 179 | if (defaultValues.value?.[name] === undefined) { 180 | return 181 | } 182 | if (modelValue.value[name] !== undefined) { 183 | return 184 | } 185 | modelValue.value = { 186 | ...modelValue.value, 187 | [name]: defaultValues.value?.[name], 188 | } 189 | }) 190 | } 191 | }) 192 | onBeforeUnmount(() => { 193 | if (injectedWrapperData) { 194 | names.value.forEach((name) => { 195 | injectedWrapperData.fields.value.delete( 196 | `${fieldGroupId}-${name}`, 197 | ) 198 | }) 199 | } 200 | }) 201 | 202 | const errors = computed(() => { 203 | if (!injectedFormData?.errors.value) { 204 | return undefined 205 | } 206 | const toReturn = names.value.reduce>>((acc, name) => { 207 | if (!injectedFormData.errors.value) { 208 | return acc 209 | } 210 | const error = get(injectedFormData.errors.value, String(name)) 211 | if (error === undefined) { 212 | return acc 213 | } 214 | acc[String(name)] = error 215 | return acc 216 | }, {}) 217 | if (Object.keys(toReturn).length === 0) { 218 | return undefined 219 | } 220 | return toReturn 221 | }) 222 | const invalidLabels = computed(() => { 223 | if (!errors.value) { 224 | return 225 | } 226 | const toReturn = Object.keys(errors.value).reduce>((acc, name) => { 227 | if (!errors.value?.[name]) { 228 | return acc 229 | } 230 | acc[namesKeysMap.value[name]] = errors.value[name]._errors 231 | return acc 232 | }, {}) 233 | if (Object.keys(toReturn).length === 0) { 234 | return 235 | } 236 | return toReturn 237 | }) 238 | const invalid = computed(() => { 239 | return errors.value !== undefined 240 | }) 241 | const invalids = computed(() => { 242 | return namesKeys.value.reduce>((acc, name) => { 243 | acc[name] = Boolean(errors.value?.[namesKeysMap.value[name]]) 244 | return acc 245 | }, {}) 246 | }) 247 | const unwatchInvalid = watch(invalid, () => { 248 | if (invalid.value) { 249 | emit('invalid', errors.value) 250 | if (injectedWrapperData) { 251 | names.value.forEach((name) => { 252 | if (!errors.value?.[name]) { 253 | injectedWrapperData.errors.value.delete( 254 | name, 255 | ) 256 | return 257 | } 258 | injectedWrapperData.errors.value.set( 259 | name, 260 | errors.value?.[name], 261 | ) 262 | }) 263 | } 264 | return 265 | } 266 | emit('valid', modelValue.value) 267 | if (injectedWrapperData) { 268 | names.value.forEach((name) => { 269 | injectedWrapperData.errors.value.delete( 270 | name, 271 | ) 272 | }) 273 | } 274 | }) 275 | const unwatchInjectedFormData = watch( 276 | () => injectedFormData?.formData, 277 | () => { 278 | emit('update:formData', injectedFormData?.formData) 279 | }, 280 | { deep: true }, 281 | ) 282 | onBeforeUnmount(() => { 283 | unwatchInvalid() 284 | unwatchInjectedFormData() 285 | }) 286 | const onUpdate = (value: Record) => { 287 | modelValue.value = value 288 | } 289 | const onUpdateField = (name: string, value: unknown) => { 290 | if (value instanceof InputEvent) { 291 | value = (value.target as HTMLInputElement).value 292 | } 293 | if (!namesKeys.value.includes(name)) { 294 | return 295 | } 296 | modelValue.value = { 297 | ...modelValue.value, 298 | [name]: value, 299 | } 300 | } 301 | const hasFieldProps = computed(() => { 302 | let toReturn = fieldProps.value 303 | if (typeof toReturn === 'function') { 304 | toReturn = toReturn(injectedFormData?.formData) 305 | } 306 | return Object.keys(toReturn).reduce>( 307 | (acc, key) => { 308 | acc[key] = unref(toReturn[key]) 309 | return acc 310 | }, 311 | {}, 312 | ) 313 | }) 314 | const isReadonly = computed(() => { 315 | if (injectedFormData?.readonly.value) { 316 | return true 317 | } 318 | return (hasFieldProps.value.readonly ?? props.readonly) as boolean 319 | }) 320 | const onUpdateEvents = computed(() => { 321 | return namesKeys.value.reduce void>>((acc, name) => { 322 | acc[`onUpdate:${name}`] = (value) => { 323 | onUpdateField(name, value) 324 | } 325 | return acc 326 | }, { 327 | 'onUpdate:modelValue': onUpdate, 328 | }) 329 | }) 330 | const hasProps = computed(() => ({ 331 | ...onUpdateEvents.value, 332 | ...hasFieldProps.value, 333 | names: hasFieldProps.value.name ?? names.value, 334 | invalid: invalid.value, 335 | invalids: invalids.value, 336 | valid: props.showValid 337 | ? Boolean(!invalid.value && modelValue.value) 338 | : undefined, 339 | invalidLabels: invalidLabels.value, 340 | modelValue: modelValue.value, 341 | readonly: isReadonly.value, 342 | })) 343 | 344 | // provide data to children 345 | provide(formFieldsGroupInjectionKey, { 346 | names: readonly(fieldsNames) as DeepReadonly>[]>>, 347 | errors: readonly(errors), 348 | }) 349 | 350 | // define component 351 | const component = computed(() => ({ 352 | render() { 353 | return ( 354 | slots.default?.({ 355 | errors: errors.value, 356 | formData: injectedFormData?.formData.value, 357 | formErrors: injectedFormData?.errors.value, 358 | invalid: invalid.value, 359 | invalids: invalids.value, 360 | invalidLabels: invalidLabels.value, 361 | modelValue: modelValue.value, 362 | onUpdate, 363 | onUpdateField, 364 | readonly: isReadonly.value, 365 | submit: injectedFormData?.submit, 366 | validate: injectedFormData?.validate, 367 | }) ?? slots.default 368 | ) 369 | }, 370 | })) 371 | 372 | return { component, hasProps, invalid } 373 | }, 374 | render() { 375 | if (this.is) { 376 | return h(this.is, this.hasProps, this.$slots) 377 | } 378 | return h(this.component, null, this.$slots) 379 | }, 380 | }) 381 | } 382 | -------------------------------------------------------------------------------- /src/VvFormTemplate.ts: -------------------------------------------------------------------------------- 1 | import type { Component, DeepReadonly, InjectionKey, PropType, SlotsType, VNode } from 'vue' 2 | import type { FormSchema, InjectedFormData, FormTemplate, RenderFunctionOutput } from './types' 3 | import type { z } from 'zod' 4 | import type { FormStatus } from './enums' 5 | import { get } from 'ts-dot-prop' 6 | import { 7 | defineComponent, 8 | h, 9 | inject, 10 | unref, 11 | } from 'vue' 12 | 13 | export function defineFormTemplate(formProvideKey: InjectionKey>, VvFormField: Component) { 14 | const VvFormTemplate = defineComponent({ 15 | name: 'VvFormTemplate', 16 | props: { 17 | schema: { 18 | type: [Array, Function] as PropType>, 19 | required: true, 20 | }, 21 | scope: { 22 | type: Object as PropType>, 23 | default: () => ({}), 24 | }, 25 | }, 26 | slots: Object as SlotsType<{ 27 | default: { 28 | errors?: DeepReadonly> 29 | formData?: undefined extends Type ? Partial> : Type 30 | invalid: boolean 31 | status?: FormStatus 32 | submit?: InjectedFormData['submit'] 33 | validate?: InjectedFormData['validate'] 34 | clear?: InjectedFormData['clear'] 35 | reset?: InjectedFormData['reset'] 36 | } 37 | }>, 38 | setup(templateProps, { slots: templateSlots }) { 39 | const injectedFormData = inject(formProvideKey) 40 | if (!injectedFormData?.formData) 41 | return 42 | return () => { 43 | const normalizedSchema = typeof templateProps.schema === 'function' 44 | ? templateProps.schema( 45 | injectedFormData, 46 | templateProps.scope, 47 | ) 48 | : templateProps.schema 49 | let lastIf: boolean | undefined 50 | const toReturn = normalizedSchema.reduce< 51 | (VNode | VNode[] | undefined)[] 52 | >((acc, field) => { 53 | const normalizedField = typeof field === 'function' 54 | ? field(injectedFormData, templateProps.scope) 55 | : field 56 | const { 57 | vvIs, 58 | vvName, 59 | vvSlots, 60 | vvChildren, 61 | vvIf, 62 | vvElseIf, 63 | vvType, 64 | vvDefaultValue, 65 | vvShowValid, 66 | vvContent, 67 | ...props 68 | } = normalizedField 69 | 70 | // conditions 71 | if (vvIf !== undefined) { 72 | if (typeof vvIf === 'string') { 73 | lastIf = Boolean( 74 | get( 75 | new Object(injectedFormData.formData.value), 76 | vvIf, 77 | ), 78 | ) 79 | } 80 | else if (typeof vvIf === 'function') { 81 | lastIf = unref(vvIf(injectedFormData)) 82 | } 83 | else { 84 | lastIf = unref(vvIf) 85 | } 86 | if (!lastIf) { 87 | return acc 88 | } 89 | } 90 | else if (vvElseIf !== undefined && lastIf !== undefined) { 91 | if (lastIf) { 92 | return acc 93 | } 94 | if (typeof vvElseIf === 'string') { 95 | lastIf = Boolean( 96 | get( 97 | new Object(injectedFormData.formData.value), 98 | vvElseIf, 99 | ), 100 | ) 101 | } 102 | else if (typeof vvElseIf === 'function') { 103 | lastIf = unref(vvElseIf(injectedFormData)) 104 | } 105 | else { 106 | lastIf = unref(vvElseIf) 107 | } 108 | if (!lastIf) { 109 | return acc 110 | } 111 | } 112 | else { 113 | lastIf = undefined 114 | } 115 | 116 | // children 117 | let hChildren: RenderFunctionOutput | { default: (scope: Record) => RenderFunctionOutput } | undefined 118 | if (vvChildren) { 119 | if (typeof vvIs === 'string') { 120 | hChildren = h(VvFormTemplate, { 121 | schema: vvChildren, 122 | }) 123 | } 124 | else { 125 | hChildren = { 126 | default: (scope: Record) => 127 | h(VvFormTemplate, { 128 | schema: vvChildren, 129 | scope, 130 | }), 131 | } 132 | } 133 | } 134 | 135 | // render 136 | if (vvName) { 137 | acc.push( 138 | h( 139 | VvFormField, 140 | { 141 | name: vvName, 142 | is: vvIs, 143 | type: vvType, 144 | defaultValue: vvDefaultValue, 145 | showValid: vvShowValid, 146 | props, 147 | }, 148 | vvSlots ?? hChildren ?? vvContent, 149 | ), 150 | ) 151 | return acc 152 | } 153 | if (vvIs) { 154 | acc.push( 155 | h( 156 | vvIs as Component, 157 | props, 158 | vvSlots ?? hChildren ?? vvContent, 159 | ), 160 | ) 161 | return acc 162 | } 163 | if (hChildren) { 164 | if ('default' in hChildren) { 165 | acc.push(hChildren.default(templateProps.scope)) 166 | } 167 | else { 168 | acc.push(hChildren) 169 | } 170 | return acc 171 | } 172 | return acc 173 | }, []) 174 | toReturn.push( 175 | templateSlots?.default?.({ 176 | errors: injectedFormData?.errors.value, 177 | formData: injectedFormData?.formData.value, 178 | invalid: injectedFormData?.invalid.value, 179 | status: injectedFormData?.status.value, 180 | submit: injectedFormData?.submit, 181 | validate: injectedFormData?.validate, 182 | clear: injectedFormData?.clear, 183 | reset: injectedFormData?.reset, 184 | }), 185 | ) 186 | return toReturn 187 | } 188 | }, 189 | }) 190 | 191 | return VvFormTemplate 192 | } 193 | -------------------------------------------------------------------------------- /src/VvFormWrapper.ts: -------------------------------------------------------------------------------- 1 | import type { DeepReadonly, InjectionKey, Ref, SlotsType } from 'vue' 2 | import type { z } from 'zod' 3 | import type { 4 | FormSchema, 5 | InjectedFormData, 6 | InjectedFormWrapperData, 7 | Path, 8 | } from './types' 9 | import { 10 | computed, 11 | defineComponent, 12 | h, 13 | inject, 14 | onBeforeUnmount, 15 | onMounted, 16 | provide, 17 | readonly, 18 | ref, 19 | toRefs, 20 | watch, 21 | } from 'vue' 22 | 23 | export function defineFormWrapper(formProvideKey: InjectionKey>, wrapperProvideKey: InjectionKey>) { 24 | return defineComponent({ 25 | name: 'VvFormWrapper', 26 | props: { 27 | name: { 28 | type: String, 29 | required: true, 30 | }, 31 | tag: { 32 | type: String, 33 | default: undefined, 34 | }, 35 | readonly: { 36 | type: Boolean, 37 | default: false, 38 | }, 39 | }, 40 | emits: ['invalid', 'valid'], 41 | expose: [ 42 | 'clear', 43 | 'errors', 44 | 'fields', 45 | 'fieldsErrors', 46 | 'formData', 47 | 'invalid', 48 | 'readonly', 49 | 'reset', 50 | 'submit', 51 | 'tag', 52 | 'validate', 53 | 'validateWrapper', 54 | ], 55 | slots: Object as SlotsType<{ 56 | default: { 57 | errors?: DeepReadonly> 58 | fieldsErrors: Map> 59 | formData?: undefined extends Type ? Partial> : Type 60 | formErrors?: DeepReadonly> 61 | invalid: boolean 62 | readonly: boolean 63 | clear?: InjectedFormData['clear'] 64 | reset?: InjectedFormData['reset'] 65 | submit?: InjectedFormData['submit'] 66 | validate?: InjectedFormData['validate'] 67 | validateWrapper?: () => Promise 68 | } 69 | }>, 70 | setup(props, { emit }) { 71 | // inject data from parent form 72 | const injectedFormData = inject(formProvideKey) 73 | // inject data from parent form wrapper 74 | const injectedWrapperData = inject(wrapperProvideKey, undefined) 75 | const fields: Ref>>> = ref(new Map()) 76 | const fieldsErrors: Ref< 77 | Map> 78 | > = ref(new Map()) 79 | const { name } = toRefs(props) 80 | 81 | // invalid 82 | const isInvalid = computed(() => { 83 | if (!injectedFormData?.invalid.value) { 84 | return false 85 | } 86 | return fieldsErrors.value.size > 0 87 | }) 88 | watch(isInvalid, (newValue) => { 89 | if (newValue) { 90 | emit('invalid') 91 | return 92 | } 93 | emit('valid') 94 | }) 95 | 96 | // readonly 97 | const isReadonly = computed(() => injectedFormData?.readonly.value || props.readonly) 98 | 99 | // provide data to child fields 100 | const providedData = { 101 | name: readonly(name), 102 | errors: fieldsErrors, 103 | invalid: readonly(isInvalid), 104 | readonly: readonly(isReadonly), 105 | fields, 106 | } 107 | provide(wrapperProvideKey, providedData) 108 | 109 | // add fields to parent wrapper 110 | const computedFields = computed(() => new Map(fields.value)) 111 | watch( 112 | computedFields, 113 | (newValue, oldValue) => { 114 | if (injectedWrapperData?.fields) { 115 | oldValue.forEach((_field, key) => { 116 | if (!newValue.has(key)) { 117 | injectedWrapperData?.fields.value.delete(key) 118 | } 119 | }) 120 | newValue.forEach((field, key) => { 121 | if (!injectedWrapperData?.fields.value.has(key)) { 122 | injectedWrapperData?.fields.value.set(key, field) 123 | } 124 | }) 125 | } 126 | }, 127 | { deep: true }, 128 | ) 129 | 130 | // add fields errors to parent wrapper 131 | watch( 132 | fieldsErrors, 133 | (newValue) => { 134 | if (injectedWrapperData?.errors) { 135 | fields.value.forEach((field) => { 136 | if (!newValue.has(field)) { 137 | injectedWrapperData.errors.value.delete(field) 138 | } 139 | if (newValue.has(field)) { 140 | const value = newValue.get(field) 141 | if (value) { 142 | injectedWrapperData.errors.value.set(field, value) 143 | } 144 | } 145 | }) 146 | } 147 | }, 148 | { deep: true }, 149 | ) 150 | 151 | onMounted(() => { 152 | if (!injectedFormData?.wrappers || !name.value) { 153 | console.warn('[@volverjs/form-vue]: Invalid wrapper registration state') 154 | return 155 | } 156 | if (injectedFormData.wrappers.has(name.value)) { 157 | console.warn(`[@volverjs/form-vue]: wrapper name "${name.value}" is already used`) 158 | return 159 | } 160 | injectedFormData.wrappers.set(name.value, providedData) 161 | }) 162 | onBeforeUnmount(() => { 163 | if (injectedFormData?.wrappers && name.value) { 164 | injectedFormData.wrappers.delete(name.value) 165 | } 166 | }) 167 | 168 | const validateWrapper = () => { 169 | return injectedFormData?.validate(undefined, { fields: new Set(fields.value.values()) }) ?? Promise.resolve(true) 170 | } 171 | 172 | return { 173 | errors: injectedFormData?.errors, 174 | fields, 175 | fieldsErrors, 176 | formData: injectedFormData?.formData, 177 | invalid: isInvalid, 178 | readonly: isReadonly, 179 | clear: injectedFormData?.clear, 180 | reset: injectedFormData?.reset, 181 | submit: injectedFormData?.submit, 182 | validate: injectedFormData?.validate, 183 | validateWrapper, 184 | } 185 | }, 186 | render() { 187 | const defaultSlot = () => 188 | this.$slots.default?.({ 189 | errors: this.errors, 190 | fieldsErrors: this.fieldsErrors, 191 | formData: this.formData, 192 | invalid: this.invalid, 193 | readonly: this.readonly, 194 | clear: this.clear, 195 | reset: this.reset, 196 | submit: this.submit, 197 | validate: this.validate, 198 | validateWrapper: this.validateWrapper, 199 | }) 200 | if (this.tag) { 201 | return h(this.tag, null, { 202 | default: defaultSlot, 203 | }) 204 | } 205 | return defaultSlot() 206 | }, 207 | }) 208 | } 209 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum FormFieldType { 2 | text = 'text', 3 | number = 'number', 4 | email = 'email', 5 | password = 'password', 6 | tel = 'tel', 7 | url = 'url', 8 | search = 'search', 9 | date = 'date', 10 | time = 'time', 11 | datetimeLocal = 'datetime-local', 12 | month = 'month', 13 | week = 'week', 14 | color = 'color', 15 | select = 'select', 16 | checkbox = 'checkbox', 17 | radio = 'radio', 18 | textarea = 'textarea', 19 | radioGroup = 'radioGroup', 20 | checkboxGroup = 'checkboxGroup', 21 | combobox = 'combobox', 22 | custom = 'custom', 23 | } 24 | 25 | export enum FormStatus { 26 | invalid = 'invalid', 27 | valid = 'valid', 28 | submitting = 'submitting', 29 | reset = 'reset', 30 | updated = 'updated', 31 | unknown = 'unknown', 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getCurrentInstance, 3 | 4 | inject, 5 | 6 | } from 'vue' 7 | import type { App, InjectionKey, Plugin } from 'vue' 8 | import type { AnyZodObject } from 'zod' 9 | import { defineForm } from './VvForm' 10 | import { defineFormField } from './VvFormField' 11 | import { defineFormFieldsGroup } from './VvFormFieldsGroup' 12 | import { defineFormWrapper } from './VvFormWrapper' 13 | import { defineFormTemplate } from './VvFormTemplate' 14 | import type { 15 | InjectedFormData, 16 | InjectedFormWrapperData, 17 | InjectedFormFieldData, 18 | InjectedFormFieldsGroupData, 19 | FormComposableOptions, 20 | FormPluginOptions, 21 | FormTemplateItem, 22 | Path, 23 | PathValue, 24 | FormSchema, 25 | FormTemplate, 26 | } from './types' 27 | 28 | function _formType(schema: Schema, options: FormComposableOptions = {}) { 29 | // create injection keys 30 | const formInjectionKey = Symbol('formInjectionKey') as InjectionKey> 31 | const formWrapperInjectionKey = Symbol('formWrapperInjectionKey') as InjectionKey< 32 | InjectedFormWrapperData 33 | > 34 | const formFieldInjectionKey = Symbol('formFieldInjectionKey') as InjectionKey< 35 | InjectedFormFieldData 36 | > 37 | const formFieldsGroupInjectionKey = Symbol('formFieldsGroupInjectionKey') as InjectionKey< 38 | InjectedFormFieldsGroupData 39 | > 40 | 41 | // create components 42 | const VvFormWrapper = defineFormWrapper( 43 | formInjectionKey, 44 | formWrapperInjectionKey, 45 | ) 46 | const VvFormField = defineFormField( 47 | formInjectionKey, 48 | formWrapperInjectionKey, 49 | formFieldInjectionKey, 50 | options, 51 | ) 52 | const VvFormFieldsGroup = defineFormFieldsGroup( 53 | formInjectionKey, 54 | formWrapperInjectionKey, 55 | formFieldsGroupInjectionKey, 56 | ) 57 | const VvFormTemplate = defineFormTemplate(formInjectionKey, VvFormField) 58 | const wrappers = new Map>() 59 | const { 60 | clear, 61 | errors, 62 | formData, 63 | ignoreUpdates, 64 | invalid, 65 | readonly, 66 | reset, 67 | status, 68 | stopUpdatesWatch, 69 | submit, 70 | validate, 71 | VvForm, 72 | } = defineForm(schema, formInjectionKey, options, VvFormTemplate, wrappers) 73 | 74 | return { 75 | clear, 76 | errors, 77 | formData, 78 | formFieldInjectionKey, 79 | formInjectionKey, 80 | formWrapperInjectionKey, 81 | ignoreUpdates, 82 | invalid, 83 | readonly, 84 | reset, 85 | status, 86 | stopUpdatesWatch, 87 | submit, 88 | validate, 89 | wrappers, 90 | VvForm, 91 | VvFormField, 92 | VvFormFieldsGroup, 93 | VvFormTemplate, 94 | VvFormWrapper, 95 | } 96 | } 97 | 98 | export const pluginInjectionKey = Symbol('pluginInjectionKey') as InjectionKey 99 | 100 | export function createForm(options: FormPluginOptions): Plugin & Partial> { 101 | let toReturn: Partial> = {} 102 | if (options.schema) { 103 | toReturn = _formType(options.schema as AnyZodObject, options) 104 | } 105 | return { 106 | ...toReturn, 107 | install(app: App, { global = false } = {}) { 108 | app.provide(pluginInjectionKey, options) 109 | 110 | if (global) { 111 | app.config.globalProperties.$vvForm = options 112 | 113 | if (toReturn?.VvForm) { 114 | app.component('VvForm', toReturn.VvForm) 115 | } 116 | if (toReturn?.VvFormWrapper) { 117 | app.component('VvFormWrapper', toReturn.VvFormWrapper) 118 | } 119 | if (toReturn?.VvFormField) { 120 | app.component('VvFormField', toReturn.VvFormField) 121 | } 122 | if (toReturn?.VvFormFieldsGroup) { 123 | app.component('VvFormFieldsGroup', toReturn.VvFormFieldsGroup) 124 | } 125 | if (toReturn?.VvFormTemplate) { 126 | app.component('VvFormTemplate', toReturn.VvFormTemplate) 127 | } 128 | } 129 | }, 130 | } 131 | } 132 | 133 | const formInstances: Map> = new Map() 134 | export function useForm(schema: Schema, options: FormComposableOptions = {}): ReturnType > { 135 | if (options.scope && formInstances.has(options.scope)) { 136 | return formInstances.get(options.scope) 137 | } 138 | if (!getCurrentInstance()) { 139 | const toReturn = _formType(schema, options) 140 | if (options.scope) { 141 | formInstances.set(options.scope, toReturn) 142 | } 143 | return toReturn 144 | } 145 | const toReturn = _formType( 146 | schema, 147 | { 148 | ...inject(pluginInjectionKey, {}), 149 | ...options, 150 | } as FormComposableOptions, 151 | ) 152 | if (options.scope) { 153 | formInstances.set(options.scope, toReturn) 154 | } 155 | return toReturn 156 | } 157 | 158 | export { FormFieldType } from './enums' 159 | export { defaultObjectBySchema } from './utils' 160 | 161 | type FormComponent = ReturnType 162 | type FormWrapperComponent = ReturnType 163 | type FormFieldComponent = ReturnType 164 | type FormFieldsGroupComponent = ReturnType 165 | type FormTemplateComponent = ReturnType 166 | 167 | export type { 168 | FormComponent, 169 | FormComposableOptions, 170 | FormFieldComponent, 171 | FormFieldsGroupComponent, 172 | FormPluginOptions, 173 | FormSchema, 174 | FormTemplate, 175 | FormTemplateComponent, 176 | FormTemplateItem, 177 | FormWrapperComponent, 178 | InjectedFormData, 179 | InjectedFormFieldData, 180 | InjectedFormWrapperData, 181 | Path, 182 | PathValue, 183 | } 184 | 185 | /** 186 | * @deprecated Use `useForm()` instead 187 | */ 188 | export function formType(schema: Schema, options: FormComposableOptions = {}) { 189 | return _formType(schema, options) 190 | } 191 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@volverjs/style/*' 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component, DeepReadonly, Ref, RendererElement, RendererNode, VNode, WatchStopHandle } from 'vue' 2 | import type { z, AnyZodObject, ZodEffects, ZodOptional, ZodTypeAny, RefinementCtx } from 'zod' 3 | import type { IgnoredUpdater } from '@vueuse/core' 4 | import type { FormFieldType, FormStatus } from './enums' 5 | 6 | type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] // Adjust the depth limit as needed 7 | 8 | type DecrementDepth = Depth[D] 9 | 10 | export type EffectType = 11 | D extends 0 12 | ? T 13 | : T | ZodOptional | ZodEffects>> 14 | 15 | export type FormSchema = EffectType 16 | 17 | export type FormFieldComponentOptions = { 18 | lazyLoad?: boolean 19 | sideEffects?: (type: `${FormFieldType}`) => Promise | void 20 | } 21 | 22 | export type FormComponentOptions = { 23 | updateThrottle?: number 24 | continuousValidation?: boolean 25 | readonly?: boolean 26 | template?: Schema extends FormSchema ? FormTemplate : never 27 | class?: Schema extends FormSchema ? new (data?: Partial>) => Type : never 28 | onUpdate?: Schema extends FormSchema 29 | ? (data?: undefined extends Type ? Partial> : Type) => void 30 | : never 31 | onSubmit?: Schema extends FormSchema 32 | ? (data?: undefined extends Type ? Partial> : Type) => void 33 | : never 34 | onReset?: Schema extends FormSchema ? (data?: undefined extends Type ? Partial> : Type) => void : never 35 | onInvalid?: Schema extends FormSchema 36 | ? (error?: z.inferFormattedError) => void 37 | : never 38 | onValid?: Schema extends FormSchema 39 | ? (data?: undefined extends Type ? Partial> : Type) => void 40 | : never 41 | } 42 | 43 | export type FormComposableOptions = FormFieldComponentOptions & 44 | FormComponentOptions & { 45 | scope?: string 46 | } 47 | 48 | type FormPluginOptionsSchema>> = { 49 | schema?: FormSchema 50 | factory?: (data?: Partial>) => T 51 | } 52 | 53 | export type FormPluginOptions = FormPluginOptionsSchema & 54 | FormComposableOptions 55 | 56 | export type InjectedFormData = { 57 | formData: Ref<(undefined extends Type ? Partial> : Type) | undefined> 58 | errors: Readonly< 59 | Ref> | undefined> 60 | > 61 | submit: () => Promise 62 | validate: (formData?: undefined extends Type ? Partial> : Type, options?: { fields?: Set>>, superRefine?: (arg: z.infer, ctx: RefinementCtx) => void | Promise }) => Promise 63 | clear: () => void 64 | reset: () => void 65 | ignoreUpdates: IgnoredUpdater 66 | stopUpdatesWatch: WatchStopHandle 67 | status: Readonly> 68 | invalid: Readonly> 69 | readonly: Ref 70 | wrappers: Map> 71 | } 72 | 73 | export type InjectedFormWrapperData = { 74 | name: Readonly> 75 | errors: Ref>> 76 | invalid: Readonly> 77 | readonly: Readonly> 78 | fields: Ref> 79 | } 80 | 81 | export type InjectedFormFieldData = { 82 | name: Readonly>>> 83 | errors: Readonly>>> 84 | } 85 | 86 | export type InjectedFormFieldsGroupData = { 87 | names: DeepReadonly>[]>> 88 | errors: Readonly> | undefined>>> 89 | } 90 | 91 | export type Primitive = 92 | | null 93 | | undefined 94 | | string 95 | | number 96 | | boolean 97 | | symbol 98 | | bigint 99 | 100 | type IsTuple = number extends T['length'] 101 | ? false 102 | : true 103 | 104 | type TupleKeys = Exclude 105 | 106 | export type PathConcat< 107 | TKey extends string | number, 108 | TValue, 109 | > = TValue extends Primitive ? `${TKey}` : `${TKey}` | `${TKey}.${Path}` 110 | 111 | export type Path = T extends readonly (infer V)[] 112 | ? IsTuple extends true 113 | ? { 114 | [K in TupleKeys]-?: PathConcat 115 | }[TupleKeys] 116 | : PathConcat 117 | : { 118 | [K in keyof T]-?: PathConcat 119 | }[keyof T] 120 | 121 | export type PathValue | Path[]> = T extends any 122 | ? TPath extends `${infer K}.${infer R}` 123 | ? K extends keyof T 124 | ? R extends Path 125 | ? undefined extends T[K] 126 | ? PathValue | undefined 127 | : PathValue 128 | : never 129 | : K extends `${number}` 130 | ? T extends readonly (infer V)[] 131 | ? PathValue> 132 | : never 133 | : never 134 | : TPath extends keyof T 135 | ? T[TPath] 136 | : TPath extends `${number}` 137 | ? T extends readonly (infer V)[] 138 | ? V 139 | : never 140 | : never 141 | : never 142 | 143 | export type AnyBoolean = 144 | | boolean 145 | | Ref 146 | | ((data?: InjectedFormData) => boolean | Ref) 147 | 148 | export type SimpleFormTemplateItem = Record< 149 | string, 150 | any 151 | > & { 152 | vvIs?: string | Component 153 | vvName?: Path> 154 | vvSlots?: Record 155 | vvChildren?: 156 | | Array< 157 | | SimpleFormTemplateItem 158 | | (( 159 | data?: InjectedFormData, 160 | scope?: Record, 161 | ) => SimpleFormTemplateItem) 162 | > 163 | | (( 164 | data?: InjectedFormData, 165 | scope?: Record, 166 | ) => Array< 167 | | SimpleFormTemplateItem 168 | | (( 169 | data?: InjectedFormData, 170 | scope?: Record, 171 | ) => SimpleFormTemplateItem) 172 | >) 173 | vvIf?: AnyBoolean | Path> 174 | vvElseIf?: AnyBoolean | Path> 175 | vvType?: `${FormFieldType}` 176 | vvShowValid?: boolean 177 | vvContent?: string 178 | vvDefaultValue?: any 179 | } 180 | 181 | export type FormTemplateItem = 182 | | SimpleFormTemplateItem 183 | | (( 184 | data?: InjectedFormData, 185 | scope?: Record, 186 | ) => SimpleFormTemplateItem) 187 | 188 | export type FormTemplate = 189 | | FormTemplateItem[] 190 | | (( 191 | data?: InjectedFormData, 192 | scope?: Record, 193 | ) => FormTemplateItem[]) 194 | 195 | export type RenderFunctionOutput = VNode 196 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | 3 | ZodDefault, 4 | ZodObject, 5 | ZodEffects, 6 | ZodSchema, 7 | ZodNullable, 8 | ZodOptional, 9 | ZodRecord, 10 | ZodArray, 11 | } from 'zod' 12 | import type { z, AnyZodObject, ZodTypeAny } from 'zod' 13 | import type { EffectType, FormSchema } from './types' 14 | 15 | export function defaultObjectBySchema(schema: Schema, original: Partial> & Record = {}): Partial> { 16 | const getSchemaInnerType = ( 17 | schema: EffectType, 18 | ) => { 19 | let toReturn = schema 20 | while (toReturn instanceof ZodEffects) { 21 | toReturn = toReturn.innerType() 22 | } 23 | if (toReturn instanceof ZodOptional) { 24 | toReturn = toReturn._def.innerType 25 | } 26 | return toReturn 27 | } 28 | const isSchemaOptional = ( 29 | schema: 30 | | Type 31 | | ZodEffects 32 | | ZodEffects> 33 | | ZodOptional, 34 | ) => { 35 | let toReturn = schema 36 | while (toReturn instanceof ZodEffects) { 37 | toReturn = toReturn.innerType() 38 | } 39 | if (toReturn instanceof ZodOptional) { 40 | return true 41 | } 42 | return false 43 | } 44 | const innerType = getSchemaInnerType(schema) 45 | const unknownKeys 46 | = innerType instanceof ZodObject 47 | ? innerType._def.unknownKeys === 'passthrough' 48 | : false 49 | return { 50 | ...(unknownKeys ? original : {}), 51 | ...Object.fromEntries( 52 | (Object.entries(innerType.shape) as [string, ZodTypeAny][]).map( 53 | ([key, subSchema]) => { 54 | const originalValue = original[key] 55 | const isOptional = isSchemaOptional(subSchema) 56 | let innerType = getSchemaInnerType(subSchema) 57 | let defaultValue: Partial> | undefined 58 | if (innerType instanceof ZodDefault) { 59 | defaultValue = innerType._def.defaultValue() 60 | innerType = innerType._def.innerType 61 | } 62 | if ( 63 | originalValue === null 64 | && innerType instanceof ZodNullable 65 | ) { 66 | return [key, originalValue] 67 | } 68 | if ((originalValue === undefined || originalValue === null) && isOptional) { 69 | return [key, defaultValue] 70 | } 71 | if (innerType instanceof ZodSchema) { 72 | const parse = subSchema.safeParse(originalValue) 73 | if (parse.success) { 74 | return [key, parse.data ?? defaultValue] 75 | } 76 | } 77 | if ( 78 | innerType instanceof ZodArray 79 | && Array.isArray(originalValue) 80 | && originalValue.length 81 | ) { 82 | const arrayType = getSchemaInnerType(innerType._def.type) 83 | if (arrayType instanceof ZodObject) { 84 | return [ 85 | key, 86 | originalValue.map((element: unknown) => 87 | defaultObjectBySchema( 88 | arrayType, 89 | (element && typeof element === 'object' 90 | ? element 91 | : undefined) as Partial< 92 | typeof arrayType 93 | >, 94 | ), 95 | ), 96 | ] 97 | } 98 | } 99 | if (innerType instanceof ZodRecord && originalValue) { 100 | const valueType = getSchemaInnerType(innerType._def.valueType) 101 | if (valueType instanceof ZodObject) { 102 | return [key, Object.keys(originalValue).reduce((acc: Record, recordKey: string) => { 103 | acc[recordKey] = defaultObjectBySchema(valueType, (originalValue as Record)[recordKey] as Partial & Record) 104 | return acc 105 | }, {})] 106 | } 107 | } 108 | if (innerType instanceof ZodObject) { 109 | return [ 110 | key, 111 | defaultObjectBySchema( 112 | innerType, 113 | originalValue 114 | && typeof originalValue === 'object' 115 | ? (originalValue as Partial> & Record) 116 | : defaultValue, 117 | ), 118 | ] 119 | } 120 | return [key, defaultValue] 121 | }, 122 | ), 123 | ), 124 | } as Partial> 125 | } 126 | -------------------------------------------------------------------------------- /test-playwright/VvForm.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/experimental-ct-vue' 2 | import VvForm from './VvForm.vue' 3 | 4 | test.use({ viewport: { width: 1000, height: 1000 } }) 5 | 6 | test('VvForm label and value', async ({ mount }) => { 7 | const component = await mount(VvForm) 8 | 9 | // check input labels 10 | const labelFirstName = await component.locator('label', { 11 | hasText: 'firstname', 12 | }) 13 | const labelSurname = await component.locator('label', { 14 | hasText: 'surname', 15 | }) 16 | await expect(labelFirstName).toHaveText('firstname') 17 | await expect(labelSurname).toHaveText('surname') 18 | 19 | // check input values 20 | const inputFirstName = await component.locator('input[name=firstname]') 21 | const inputSurname = await component.locator('input[name=surname]') 22 | await expect(inputFirstName).toHaveValue('Massimo') 23 | await expect(inputSurname).toHaveValue('Rossi') 24 | }) 25 | 26 | test('VvForm events', async ({ mount }) => { 27 | let submitted = false 28 | let invalid = false 29 | let reset = false 30 | 31 | const component = await mount(VvForm, { 32 | on: { 33 | submit: () => (submitted = true), 34 | invalid: () => (invalid = true), 35 | valid: () => (invalid = false), 36 | reset: () => (reset = true), 37 | }, 38 | }) 39 | 40 | const buttonSubmit = await component.locator('button[type=submit]') 41 | const buttonReset = await component.locator('button[type=reset]') 42 | const inputAge = await component.locator('input[name=age]') 43 | const inputFirstName = await component.locator('input[name=firstname]') 44 | const inputSurname = await component.locator('input[name=surname]') 45 | await expect(buttonSubmit).toContainText('Submit') 46 | await expect(buttonReset).toContainText('Reset') 47 | await expect(inputAge).toHaveValue('18') 48 | await expect(inputFirstName).toHaveValue('Massimo') 49 | await expect(inputSurname).toHaveValue('Rossi') 50 | 51 | // Trigger submit event 52 | await buttonSubmit.click() 53 | 54 | // Check valid and submitted events 55 | expect(submitted).toBeTruthy() 56 | expect(invalid).toBeFalsy() 57 | expect(reset).toBeFalsy() 58 | 59 | // Reset events 60 | submitted = false 61 | invalid = false 62 | reset = false 63 | 64 | // Set valid input value and submit 65 | await inputAge.fill('10') 66 | await buttonSubmit.click() 67 | 68 | // Check valid and submitted events 69 | expect(submitted).toBeFalsy() 70 | expect(invalid).toBeTruthy() 71 | expect(reset).toBeFalsy() 72 | 73 | // Reset events 74 | submitted = false 75 | invalid = false 76 | reset = false 77 | 78 | // Reset form 79 | await buttonReset.click() 80 | expect(submitted).toBeFalsy() 81 | expect(invalid).toBeFalsy() 82 | expect(reset).toBeTruthy() 83 | 84 | // Check input values 85 | await expect(inputAge).toHaveValue('') 86 | await expect(inputFirstName).toHaveValue('') 87 | await expect(inputSurname).toHaveValue('') 88 | }) 89 | 90 | test('VvForm continuousValidation', async ({ mount }) => { 91 | let invalid = false 92 | let valid = false 93 | 94 | const component = await mount(VvForm, { 95 | props: { 96 | continuousValidation: true, 97 | }, 98 | on: { 99 | invalid: () => (invalid = true), 100 | valid: () => (valid = true), 101 | }, 102 | }) 103 | 104 | // check input values 105 | const inputNumberButtonGroups = await component.locator( 106 | '.vv-input-text__action-chevron', 107 | ) 108 | const inputNumberButtonDown = await inputNumberButtonGroups.last() 109 | const inputNumberButtonUp = await inputNumberButtonGroups.first() 110 | const inputHint = await component.locator('.vv-input-text__hint') 111 | 112 | await inputNumberButtonDown.click() 113 | await inputHint.waitFor({ state: 'visible' }) 114 | 115 | await inputNumberButtonUp.click() 116 | await inputHint.waitFor({ state: 'hidden' }) 117 | expect(valid).toBeTruthy() 118 | 119 | await inputNumberButtonDown.click() 120 | await inputHint.waitFor({ state: 'visible' }) 121 | expect(invalid).toBeTruthy() 122 | }) 123 | -------------------------------------------------------------------------------- /test-playwright/VvForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 49 | -------------------------------------------------------------------------------- /test-playwright/VvFormField.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/experimental-ct-vue' 2 | import VvFormField from './VvFormField.vue' 3 | 4 | test.use({ viewport: { width: 1000, height: 1000 } }) 5 | 6 | test('Valid VvFormField', async ({ mount }) => { 7 | const component = await mount(VvFormField) 8 | 9 | // check firstname is valid 10 | const inputTextFirstName = await component.locator('.vv-input-text--valid') 11 | await expect(inputTextFirstName).toHaveText('firstname') 12 | }) 13 | 14 | test('Label and Value VvFormField', async ({ mount }) => { 15 | const component = await mount(VvFormField) 16 | 17 | // check input labels 18 | const labelFirstName = await component.locator('label', { 19 | hasText: 'firstname', 20 | }) 21 | const labelSurname = await component.locator('label', { 22 | hasText: 'surname', 23 | }) 24 | await expect(labelFirstName).toHaveText('firstname') 25 | await expect(labelSurname).toHaveText('surname') 26 | 27 | // check input values 28 | const inputFirstName = await component.locator('input[name=firstname]') 29 | const inputSurname = await component.locator('input[name=surname]') 30 | await expect(inputFirstName).toHaveValue('Massimo') 31 | await expect(inputSurname).toHaveValue('Rossi') 32 | }) 33 | -------------------------------------------------------------------------------- /test-playwright/VvFormField.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 83 | -------------------------------------------------------------------------------- /test-playwright/VvFormFieldsGroup.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/experimental-ct-vue' 2 | import VvFormFieldsGroup from './VvFormFieldsGroup.vue' 3 | 4 | test.use({ viewport: { width: 1000, height: 1000 } }) 5 | 6 | test('VvFormFieldsGroup events', async ({ mount }) => { 7 | let submitted = false 8 | let invalid = false 9 | let data: unknown 10 | 11 | const component = await mount(VvFormFieldsGroup, { 12 | on: { 13 | submit: (submittedData: unknown) => { 14 | submitted = true 15 | data = submittedData 16 | }, 17 | invalid: () => (invalid = true), 18 | valid: () => (invalid = false), 19 | }, 20 | }) 21 | const buttonSubmit = await component.locator('button[type=submit]') 22 | const inputName = await component.locator('[name=name]') 23 | const inputSurname = await component.locator('[name=surname]') 24 | 25 | await inputName.fill('John') 26 | await inputSurname.fill('Doe') 27 | 28 | // Trigger submit event 29 | await buttonSubmit.click() 30 | 31 | // Check valid and submitted events 32 | expect(submitted).toBeTruthy() 33 | expect(invalid).toBeFalsy() 34 | expect(data).toEqual({ firstname: 'John', lastname: 'Doe' }) 35 | 36 | // Reset events 37 | submitted = false 38 | invalid = false 39 | data = undefined 40 | 41 | await inputName.fill('') 42 | await inputSurname.fill('') 43 | await buttonSubmit.click() 44 | 45 | // Check invalid event 46 | expect(submitted).toBeFalsy() 47 | expect(invalid).toBeTruthy() 48 | expect(data).toBeUndefined() 49 | }) 50 | -------------------------------------------------------------------------------- /test-playwright/VvFormFieldsGroup.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 51 | -------------------------------------------------------------------------------- /test-playwright/VvFormTemplate.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/experimental-ct-vue' 2 | import VvFormTemplate from './VvFormTemplate.vue' 3 | 4 | test.use({ viewport: { width: 1000, height: 1000 } }) 5 | 6 | test('Valid VvFormTemplate', async ({ mount }) => { 7 | const component = await mount(VvFormTemplate) 8 | 9 | // check firstname is valid 10 | const inputTextFirstName = await component.locator('.vv-input-text--valid') 11 | await expect(inputTextFirstName).toHaveText('firstname') 12 | }) 13 | 14 | test('Label and Value VvFormTemplate', async ({ mount }) => { 15 | const component = await mount(VvFormTemplate) 16 | 17 | // check input labels 18 | const labelFirstName = await component.locator('label', { 19 | hasText: 'firstname', 20 | }) 21 | const labelSurname = await component.locator('label', { 22 | hasText: 'surname', 23 | }) 24 | const labelCity = await component.locator('label', { 25 | hasText: 'city', 26 | }) 27 | await expect(labelFirstName).toHaveText('firstname') 28 | await expect(labelSurname).toHaveText('surname') 29 | await expect(labelCity).toHaveText('city') 30 | 31 | // check input values 32 | const inputFirstName = await component.locator('input[name=firstname]') 33 | const inputSurname = await component.locator('input[name=surname]') 34 | await expect(inputFirstName).toHaveValue('Massimo') 35 | await expect(inputSurname).toHaveValue('Rossi') 36 | }) 37 | -------------------------------------------------------------------------------- /test-playwright/VvFormTemplate.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 97 | -------------------------------------------------------------------------------- /test-playwright/VvFormWrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/experimental-ct-vue' 2 | import VvFormWrapper from './VvFormWrapper.vue' 3 | 4 | test.use({ viewport: { width: 1000, height: 1000 } }) 5 | 6 | test('Invalid VvFormWrapper', async ({ mount }) => { 7 | const component = await mount(VvFormWrapper) 8 | 9 | // check form wrapper fields and invalid state 10 | const section1 = await component.locator('.form-section-1') 11 | 12 | const invalidMessage = await section1.locator('#section-wrapper-hint') 13 | await expect(invalidMessage).toHaveText('There is a validation error in this section') 14 | 15 | // check form field into wrapper invalid state 16 | const invalidLabels = await section1.locator('small[role=alert]') 17 | await expect(invalidLabels).toHaveCount(1) 18 | }) 19 | 20 | test('Label and Value VvFormField into VvFormWrapper', async ({ mount }) => { 21 | const component = await mount(VvFormWrapper) 22 | 23 | // check input labels 24 | const labelFirstName = await component.locator('label', { 25 | hasText: 'firstname', 26 | }) 27 | const labelSurname = await component.locator('label', { 28 | hasText: 'surname', 29 | }) 30 | await expect(labelFirstName).toHaveText('firstname') 31 | await expect(labelSurname).toHaveText('surname') 32 | 33 | // check input values 34 | const inputFirstName = await component.locator('input[name=firstname]') 35 | const inputSurname = await component.locator('input[name=surname]') 36 | const inputCity = await component.locator('input[name=location\\.city]') 37 | await expect(inputFirstName).toHaveValue('Massimo') 38 | await expect(inputSurname).toHaveValue('Rossi') 39 | await expect(inputCity).toHaveValue('Verona') 40 | }) 41 | 42 | test('VvFormWrapper partial validation', async ({ mount }) => { 43 | const component = await mount(VvFormWrapper) 44 | 45 | // Check form wrapper fields and invalid state 46 | const section1 = await component.locator('.form-section-1') 47 | 48 | const invalidMessage = await section1.locator('#section-wrapper-hint') 49 | const invalidLabels = await component.locator('small[role=alert]') 50 | await expect(invalidMessage).toHaveText('There is a validation error in this section') 51 | await expect(invalidLabels).toHaveCount(1) 52 | 53 | // Reset form 54 | const buttonReset = await component.locator('button[type=reset]') 55 | await buttonReset.click() 56 | 57 | // check input values 58 | const inputFirstName = await component.locator('input[name=firstname]') 59 | const inputSurname = await component.locator('input[name=surname]') 60 | const inputCity = await component.locator('input[name=location\\.city]') 61 | await expect(inputFirstName).toHaveValue('') 62 | await expect(inputSurname).toHaveValue('') 63 | await expect(inputCity).toHaveValue('') 64 | 65 | // check input labels 66 | await expect(invalidMessage).toHaveCount(0) 67 | await expect(invalidLabels).toHaveCount(0) 68 | 69 | // Partial validation 70 | const partialValidationButton = await component.locator('#validation-button') 71 | await partialValidationButton.click() 72 | 73 | // check form wrapper fields and invalid state 74 | await expect(invalidMessage).toHaveText('There is a validation error in this section') 75 | await expect(invalidLabels).toHaveCount(5) 76 | }) 77 | -------------------------------------------------------------------------------- /test-playwright/VvFormWrapper.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 71 | -------------------------------------------------------------------------------- /test-playwright/components/NameSurname.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /test-playwright/components/ScopedSlot.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /test-vitest/defaultObjectBySchema.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { it, expect } from 'vitest' 3 | // @ts-ignore 4 | import { defaultObjectBySchema } from '../dist/index.es' 5 | 6 | it('simple object', async () => { 7 | const schema = z.object({ 8 | name: z.string(), 9 | surname: z.string(), 10 | }) 11 | 12 | const defaultObject = defaultObjectBySchema(schema) 13 | 14 | expect(defaultObject).toStrictEqual({ name: undefined, surname: undefined }) 15 | }) 16 | 17 | it('object with defaults', async () => { 18 | const schema = z.object({ 19 | name: z.string().default(''), 20 | surname: z.string(), 21 | age: z.number().default(0), 22 | }) 23 | 24 | const defaultObject = defaultObjectBySchema(schema) 25 | 26 | expect(defaultObject).toStrictEqual({ 27 | name: '', 28 | surname: undefined, 29 | age: 0, 30 | }) 31 | }) 32 | 33 | it('nested object', async () => { 34 | const schema = z.object({ 35 | name: z.string().default(''), 36 | surname: z.string(), 37 | address: z.object({ 38 | city: z.string(), 39 | country: z.string(), 40 | }), 41 | }) 42 | 43 | const defaultObject = defaultObjectBySchema(schema) 44 | 45 | expect(defaultObject).toStrictEqual({ 46 | name: '', 47 | surname: undefined, 48 | address: { 49 | city: undefined, 50 | country: undefined, 51 | }, 52 | }) 53 | }) 54 | 55 | it('nested object with defaults', async () => { 56 | const schema = z.object({ 57 | name: z.string().default(''), 58 | surname: z.string(), 59 | address: z.object({ 60 | city: z.string().default(''), 61 | country: z.string(), 62 | }), 63 | }) 64 | 65 | const defaultObject = defaultObjectBySchema(schema) 66 | 67 | expect(defaultObject).toStrictEqual({ 68 | name: '', 69 | surname: undefined, 70 | address: { 71 | city: '', 72 | country: undefined, 73 | }, 74 | }) 75 | }) 76 | 77 | it('keep original value', async () => { 78 | const schema = z.object({ 79 | name: z.string(), 80 | }) 81 | 82 | const defaultObject = defaultObjectBySchema(schema, { name: 'John' }) 83 | 84 | expect(defaultObject).toStrictEqual({ name: 'John' }) 85 | }) 86 | 87 | it('wrong original type to undefined', async () => { 88 | const schema = z.object({ 89 | name: z.string(), 90 | }) 91 | 92 | const defaultObject = defaultObjectBySchema(schema, { name: 1 }) 93 | 94 | expect(defaultObject).toStrictEqual({ name: undefined }) 95 | }) 96 | 97 | it('not nullable', async () => { 98 | const schema = z.object({ 99 | name: z.string(), 100 | }) 101 | 102 | const defaultObject = defaultObjectBySchema(schema, { name: null }) 103 | 104 | expect(defaultObject).toStrictEqual({ name: undefined }) 105 | }) 106 | 107 | it('nullable', async () => { 108 | const schema = z.object({ 109 | name: z.string().nullable(), 110 | }) 111 | 112 | const defaultObject = defaultObjectBySchema(schema, { name: null }) 113 | 114 | expect(defaultObject).toStrictEqual({ name: null }) 115 | }) 116 | 117 | it('coerce to type string', async () => { 118 | const schema = z.object({ 119 | name: z.coerce.string(), 120 | }) 121 | 122 | const defaultObject = defaultObjectBySchema(schema, { name: 1138 }) 123 | 124 | expect(defaultObject).toStrictEqual({ name: '1138' }) 125 | }) 126 | 127 | it('coerce to type number', async () => { 128 | const schema = z.object({ 129 | age: z.coerce.number(), 130 | }) 131 | 132 | const defaultObject = defaultObjectBySchema(schema, { age: '22' }) 133 | 134 | expect(defaultObject).toStrictEqual({ age: 22 }) 135 | }) 136 | 137 | it('coerce to type number without default', async () => { 138 | const schema = z.object({ 139 | age: z.coerce.number(), 140 | }) 141 | 142 | const defaultObject = defaultObjectBySchema(schema, { age: 'John' }) 143 | 144 | expect(defaultObject).toStrictEqual({ age: undefined }) 145 | }) 146 | 147 | it('coerce to type number with default', async () => { 148 | const schema = z.object({ 149 | age: z.coerce.number().default(0), 150 | }) 151 | 152 | const defaultObject = defaultObjectBySchema(schema, { age: 'John' }) 153 | 154 | expect(defaultObject).toStrictEqual({ age: 0 }) 155 | }) 156 | 157 | it('strip', async () => { 158 | const schema = z.object({ 159 | name: z.string(), 160 | surname: z.string(), 161 | }) 162 | 163 | const defaultObject = defaultObjectBySchema(schema, { age: 21 }) 164 | expect(defaultObject).toStrictEqual({ 165 | name: undefined, 166 | surname: undefined, 167 | }) 168 | }) 169 | 170 | it('passthrough', async () => { 171 | const schema = z 172 | .object({ 173 | name: z.string(), 174 | surname: z.string(), 175 | }) 176 | .passthrough() 177 | 178 | const defaultObject = defaultObjectBySchema(schema, { age: 21 }) 179 | 180 | expect(defaultObject).toStrictEqual({ 181 | name: undefined, 182 | surname: undefined, 183 | age: 21, 184 | }) 185 | }) 186 | 187 | it('optional', async () => { 188 | const schema = z.object({ 189 | name: z.string(), 190 | surname: z.string(), 191 | location: z.object({ 192 | city: z.string().optional(), 193 | address: z.object({ 194 | street: z 195 | .object({ 196 | name: z.string().optional(), 197 | number: z.number().optional(), 198 | }) 199 | .optional(), 200 | }), 201 | }), 202 | }) 203 | 204 | const defaultObject = defaultObjectBySchema(schema, { 205 | name: 'John', 206 | location: { 207 | city: 'Verona', 208 | address: { street: { name: null, number: 1 } }, 209 | }, 210 | }) 211 | expect(defaultObject).toStrictEqual({ 212 | name: 'John', 213 | surname: undefined, 214 | location: { 215 | city: 'Verona', 216 | address: { street: { name: undefined, number: 1 } }, 217 | }, 218 | }) 219 | }) 220 | -------------------------------------------------------------------------------- /test-vitest/useForm.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { it, expect } from 'vitest' 3 | // @ts-ignore 4 | import { useForm } from '../dist/index.es' 5 | 6 | it('mount component', async () => { 7 | const { VvForm, VvFormField, VvFormWrapper } = useForm( 8 | z.object({ 9 | name: z.string(), 10 | surname: z.string(), 11 | }), 12 | { 13 | lazyLoad: true, 14 | }, 15 | ) 16 | 17 | expect(VvForm).toBeTruthy() 18 | expect(VvFormField).toBeTruthy() 19 | expect(VvFormWrapper).toBeTruthy() 20 | }) 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "useDefineForClassFields": true, 7 | "baseUrl": ".", 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "resolveJsonModule": true, 11 | "types": ["vitest/globals", "vite/client"], 12 | "strict": true, 13 | "strictBindCallApply": true, 14 | "strictFunctionTypes": true, 15 | "strictNullChecks": true, 16 | "strictPropertyInitialization": true, 17 | "alwaysStrict": true, 18 | "noImplicitAny": true, 19 | "noImplicitReturns": true, 20 | // `"noImplicitThis": true` is part of `strict` 21 | // Added again here in case some users decide to disable `strict`. 22 | // This enables stricter inference for data properties on `this`. 23 | "noImplicitThis": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": false, 26 | "declaration": true, 27 | "outDir": "dist", 28 | // Recommended 29 | "esModuleInterop": true, 30 | "forceConsistentCasingInFileNames": true, 31 | // `"verbatimModuleSyntax": true` replaces isolatedModules, preserveValueImports and importsNotUsedAsValues 32 | "verbatimModuleSyntax": true, 33 | "skipLibCheck": true, 34 | "noErrorTruncation": true 35 | }, 36 | "include": ["src/*", "test-vitest/*", "test-playwright/*"] 37 | } 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { configDefaults, defineConfig } from 'vitest/config' 3 | import vue from '@vitejs/plugin-vue' 4 | import ESLint from '@nabla/vite-plugin-eslint' 5 | import dts from 'vite-plugin-dts' 6 | 7 | // https://vitejs.dev/config/ 8 | export default () => { 9 | return defineConfig({ 10 | test: { 11 | globals: true, 12 | environment: 'happy-dom', 13 | exclude: [...configDefaults.exclude, 'test-playwright/**'], 14 | }, 15 | build: { 16 | lib: { 17 | name: '@volverjs/form-vue', 18 | entry: path.resolve(__dirname, 'src/index.ts'), 19 | fileName: format => `index.${format}.js`, 20 | }, 21 | rollupOptions: { 22 | external: [ 23 | 'vue', 24 | 'zod', 25 | '@vueuse/core', 26 | /^@volverjs(?:\/.+)?$/, 27 | ], 28 | output: { 29 | exports: 'named', 30 | globals: { 31 | 'vue': 'Vue', 32 | 'zod': 'zod', 33 | '@vueuse/core': 'VueUseCore', 34 | }, 35 | }, 36 | }, 37 | }, 38 | plugins: [ 39 | // https://github.com/vitejs/vite-plugin-vue 40 | vue(), 41 | 42 | // https://github.com/gxmari007/vite-plugin-eslint 43 | ESLint(), 44 | 45 | // https://github.com/qmhc/vite-plugin-dts 46 | dts({ 47 | insertTypesEntry: true, 48 | exclude: ['**/test-*/**'], 49 | }), 50 | ], 51 | }) 52 | } 53 | --------------------------------------------------------------------------------