├── .nvmrc ├── .npmrc ├── pnpm-workspace.yaml ├── docs ├── .vitepress │ ├── cache │ │ └── deps │ │ │ ├── package.json │ │ │ ├── vue.js.map │ │ │ ├── _metadata.json │ │ │ ├── vitepress___@vue_devtools-api.js │ │ │ ├── vue.js │ │ │ └── vitepress___@vue_devtools-api.js.map │ ├── theme │ │ ├── index.ts │ │ ├── components │ │ │ └── HomeTeam.vue │ │ └── style.css │ └── config.ts ├── public │ ├── favicon.ico │ ├── assets │ │ ├── mango.png │ │ ├── devtools.png │ │ ├── headless.png │ │ ├── vue-i18n.png │ │ ├── devtools-field.png │ │ ├── devtools-forms.png │ │ ├── devtools-select.png │ │ ├── feather.svg │ │ ├── devtools.svg │ │ ├── rocket.svg │ │ ├── ts.svg │ │ ├── world.svg │ │ └── zod.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── mstile-150x150.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── browserconfig.xml │ └── site.webmanifest ├── playground.md ├── guide │ ├── devtools.md │ └── getting-started.md ├── index.md ├── examples │ ├── external-errors.md │ └── subforms.md ├── CHANGELOG.md ├── best-practices │ ├── custom-input.md │ └── i18n.md └── api │ ├── field.md │ ├── field-array.md │ └── useForm.md ├── .vscode └── settings.json ├── src ├── lib │ ├── index.ts │ └── formatErrors.ts ├── index.ts ├── types │ ├── index.ts │ ├── devtools.type.ts │ ├── standardSpec.type.ts │ ├── utils.type.ts │ └── eager.type.ts ├── devtools │ ├── devtoolsBuilder.ts │ └── devtools.ts └── utils │ └── index.ts ├── .gitignore ├── eslint.config.js ├── tsconfig.json ├── .github ├── workflows │ ├── release.yml │ ├── docs.yml │ └── ci.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── test ├── useFormReactiveInitialState.test.ts ├── useFormIsChanged.test.ts ├── useFormComplexForm.test.ts ├── testUtils.ts ├── useFormIsTouched.test.ts ├── useFormArrayModifiers.test.ts ├── useFormSetValue.test.ts ├── useFormValueSync.test.ts ├── useFormReset.test.ts ├── useFormBlurAll.test.ts ├── useFormUnregister.test.ts ├── useFormIsDirty.test.ts ├── useFormNestedArray.test.ts ├── wiserTemplateSchema.ts ├── useFormFieldRegister.test.ts ├── useFormSubmit.test.ts ├── useFormInitialStateNull.test.ts ├── useFormIsValid.test.ts ├── useFormErrors.test.ts └── useFormRegister.test.ts ├── LICENSE ├── CHANGELOG.md ├── package.json ├── README.md ├── logo.svg └── CODE_OF_CONDUCT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v23 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shamefully-hoist=true 3 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - examples/* 4 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.experimental.useFlatConfig": false 3 | } 4 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/assets/mango.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/mango.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/assets/devtools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/devtools.png -------------------------------------------------------------------------------- /docs/public/assets/headless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/headless.png -------------------------------------------------------------------------------- /docs/public/assets/vue-i18n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/vue-i18n.png -------------------------------------------------------------------------------- /docs/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/mstile-150x150.png -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { formatErrorsToZodFormattedError } from './formatErrors' 2 | export { useForm } from './useForm' 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib' 2 | export type { 3 | Field, FieldArray, Form, FormattedError, UseForm, 4 | } from './types' 5 | -------------------------------------------------------------------------------- /docs/public/assets/devtools-field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/devtools-field.png -------------------------------------------------------------------------------- /docs/public/assets/devtools-forms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/devtools-forms.png -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/assets/devtools-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wisemen-digital/vue-formango/HEAD/docs/public/assets/devtools-select.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common.type' 2 | export * from './eager.type' 3 | export * from './form.type' 4 | export * from './standardSpec.type' 5 | export * from './utils.type' 6 | -------------------------------------------------------------------------------- /docs/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import WisemenEslintConfig from '@wisemen/eslint-config-vue' 2 | 3 | export default [ 4 | ...(await WisemenEslintConfig), 5 | { 6 | rules: { 7 | 'require-explicit-generics/require-explicit-generics': 'off', 8 | 'ts/explicit-function-return-type': 'off', 9 | }, 10 | }, 11 | 12 | ] 13 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import './style.css' 3 | 4 | import Theme from 'vitepress/theme' 5 | import { h } from 'vue' 6 | 7 | export default { 8 | enhanceApp() { 9 | // ... 10 | }, 11 | extends: Theme, 12 | Layout: () => { 13 | return h(Theme.Layout, null, {}) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /docs/playground.md: -------------------------------------------------------------------------------- 1 | # Playground 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["esnext", "DOM"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "skipDefaultLibCheck": true, 12 | "skipLibCheck": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/guide/devtools.md: -------------------------------------------------------------------------------- 1 | # Devtools 2 | 3 | If you have the official [Vue Devtools](https://devtools.vuejs.org/) installed, whenever you make a form, it will be viewable inside the formango tab. 4 | 5 | First select the Formango tab. 6 | 7 | Afterwards you will see all the registered forms that are currently active, which are named by the component they are registered in. 8 | 9 | If you register a field, it will become visible inside the form. You can select individual fields and viewing their details inside the devtools. 10 | 11 |  12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: 9 23 | 24 | - name: Set node 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 22.x 28 | cache: pnpm 29 | 30 | - run: npx changelogithub 31 | env: 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /test/useFormReactiveInitialState.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | import { ref } from 'vue' 7 | 8 | import { useForm } from '../src/lib/useForm' 9 | import { 10 | basicSchema, 11 | sleep, 12 | } from './testUtils' 13 | 14 | describe('reactive initial state', () => { 15 | it('should update the state when the initial state is updated', async () => { 16 | const initialState = ref({ name: 'John' }) 17 | 18 | const form = useForm({ 19 | initialState, 20 | schema: basicSchema, 21 | onSubmit: (data) => { 22 | return data 23 | }, 24 | }) 25 | 26 | const name = form.register('name') 27 | 28 | expect(name.modelValue.value).toBe('John') 29 | 30 | initialState.value = { name: 'Joe' } 31 | 32 | await sleep(0) 33 | 34 | expect(name.modelValue.value).toBe('Joe') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/useFormIsChanged.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { basicSchema } from './testUtils' 9 | 10 | describe('isChanged', () => { 11 | it('should be false by default', () => { 12 | const form = useForm({ 13 | schema: basicSchema, 14 | onSubmit: (data) => { 15 | return data 16 | }, 17 | 18 | }) 19 | 20 | const name = form.register('name') 21 | 22 | expect(name.isChanged.value).toBeFalsy() 23 | }) 24 | 25 | it('should be true when `onChange` is called', () => { 26 | const form = useForm({ 27 | schema: basicSchema, 28 | onSubmit: (data) => { 29 | return data 30 | }, 31 | }) 32 | 33 | const name = form.register('name') 34 | 35 | name.onChange() 36 | 37 | expect(name.isChanged.value).toBeTruthy() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/types/devtools.type.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Field, 3 | Form, 4 | } from '.' 5 | 6 | export type NodeTypes = 'field' | 'form' 7 | 8 | export interface SelectedNodeForm { 9 | formId: string 10 | name: string 11 | type: 'form' 12 | } 13 | 14 | export interface SelectedNodeField { 15 | fieldId: string 16 | formId: string 17 | type: 'field' 18 | } 19 | 20 | export interface EncodedNode { 21 | id: string 22 | name?: string 23 | type: NodeTypes 24 | } 25 | 26 | export type SelectedNode = SelectedNodeField | SelectedNodeForm 27 | 28 | interface ObjectWithPossiblyField { 29 | __FIELD__?: Field & { __ID__: string } 30 | } 31 | 32 | export type ObjectWithPossiblyFieldRecursive = ObjectWithPossiblyField & { 33 | [x: string]: ObjectWithPossiblyFieldRecursive 34 | } 35 | 36 | export interface FormNode { 37 | name?: string 38 | form: Form 39 | type: 'form' 40 | } 41 | 42 | export interface FieldNode { 43 | field: { 44 | formId: string 45 | field: Field 46 | } 47 | type: 'field' 48 | } 49 | -------------------------------------------------------------------------------- /docs/public/assets/feather.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/useFormComplexForm.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src' 8 | import type { TemplateFormOpenQuestion } from './wiserTemplateSchema' 9 | import { 10 | QuestionType, 11 | templateUpdateFormSchema, 12 | } from './wiserTemplateSchema' 13 | 14 | const EMPTY_QUESTION = { 15 | uuid: null, 16 | title: '', 17 | hasExtraInformation: false, 18 | isRequired: true, 19 | description: null, 20 | questionType: QuestionType.OPEN, 21 | } satisfies TemplateFormOpenQuestion 22 | 23 | describe('when using a complex schema', () => { 24 | it('should work with nested arrays', () => { 25 | const form = useForm({ 26 | schema: templateUpdateFormSchema, 27 | onSubmit: (data) => { 28 | return data 29 | }, 30 | 31 | }) 32 | const steps = form.registerArray('steps') 33 | const questions = steps.registerArray('0.questions') 34 | 35 | questions.append(EMPTY_QUESTION) 36 | 37 | expect(questions.modelValue.value).toEqual([ 38 | EMPTY_QUESTION, 39 | ]) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Wisemen 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 | -------------------------------------------------------------------------------- /test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const basicSchema = z.object({ name: z.string().min(4) }) 4 | 5 | export const basicWithSimilarNamesSchema = z.object({ 6 | name: z.string(), 7 | nameFirst: z.string().min(4), 8 | nameSecond: z.string().min(4), 9 | }) 10 | 11 | export const objectSchema = z.object({ 12 | a: z.object({ 13 | b: z.string(), 14 | bObj: z.object({ c: z.string() }), 15 | }), 16 | }) 17 | 18 | export const basicArraySchema = z.object({ array: z.array(z.string()) }) 19 | 20 | export const basic2DArraySchema = z.object({ array: z.array(z.array(z.string())) }) 21 | 22 | export const objectArraySchema = z.object({ 23 | array: z.array( 24 | z.object({ name: z.string() }), 25 | ), 26 | }) 27 | 28 | export const twoDimensionalArraySchema = z.object({ 29 | array: z.array( 30 | z.array( 31 | z.object({ name: z.string() }), 32 | ), 33 | ), 34 | }) 35 | 36 | export const nestedArraySchema = z.object({ 37 | users: z.array( 38 | z.array( 39 | z.object({ name: z.string() }), 40 | ), 41 | ), 42 | }) 43 | export const fieldWithArraySchema = z.object({ field: z.object({ array: z.array(z.string()) }) }) 44 | 45 | export function sleep(ms: number) { 46 | return new Promise((resolve) => setTimeout(resolve, ms)) 47 | } 48 | -------------------------------------------------------------------------------- /test/useFormIsTouched.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | objectSchema, 11 | } from './testUtils' 12 | 13 | describe('isTouched', () => { 14 | it('should be false by default', () => { 15 | const form = useForm({ 16 | schema: basicSchema, 17 | onSubmit: (data) => { 18 | return data 19 | }, 20 | }) 21 | 22 | const name = form.register('name') 23 | 24 | expect(name.isTouched.value).toBeFalsy() 25 | }) 26 | 27 | it('should be true when `onBlur` is called', () => { 28 | const form = useForm({ 29 | schema: basicSchema, 30 | onSubmit: (data) => { 31 | return data 32 | }, 33 | }) 34 | 35 | const name = form.register('name') 36 | 37 | name.onBlur() 38 | 39 | expect(name.isTouched.value).toBeTruthy() 40 | }) 41 | 42 | it('should be touched when a child field is touched', () => { 43 | const form = useForm({ 44 | schema: objectSchema, 45 | onSubmit: (data) => { 46 | return data 47 | }, 48 | }) 49 | 50 | const a = form.register('a') 51 | const b = a.register('b') 52 | 53 | b.onBlur() 54 | 55 | expect(a.isTouched.value).toBeTruthy() 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /docs/public/assets/devtools.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 13 | 17 | 21 | 25 | 29 | 33 | 37 | 38 | -------------------------------------------------------------------------------- /test/useFormArrayModifiers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicArraySchema, 10 | sleep, 11 | } from './testUtils' 12 | 13 | describe('array field modifiers', () => { 14 | it('should move a field in an array', () => { 15 | const form = useForm({ 16 | schema: basicArraySchema, 17 | onSubmit: (data) => { 18 | return data 19 | }, 20 | }) 21 | 22 | const array = form.registerArray('array') 23 | 24 | array.append('John') 25 | array.append('Doe') 26 | 27 | array.move(1, 0) 28 | 29 | expect(form.state.value).toEqual({ 30 | array: [ 31 | 'Doe', 32 | 'John', 33 | ], 34 | }) 35 | }) 36 | 37 | it('shoud move a field in an array and back', async () => { 38 | const form = useForm({ 39 | schema: basicArraySchema, 40 | onSubmit: (data) => { 41 | return data 42 | }, 43 | }) 44 | 45 | const array = form.registerArray('array') 46 | 47 | array.append('John') 48 | array.append() 49 | 50 | array.move(1, 0) 51 | 52 | await sleep(0) 53 | 54 | array.move(0, 1) 55 | 56 | expect(form.state.value).toEqual({ 57 | array: [ 58 | 'John', 59 | null, 60 | ], 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /docs/public/assets/rocket.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repo 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v3 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: latest 27 | run_install: true 28 | 29 | - name: Build project 30 | run: pnpm run build 31 | 32 | - name: Build documentation 33 | run: pnpm run docs:build 34 | 35 | - name: Upload production-ready build files 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: production-files 39 | path: ./docs/.vitepress/dist 40 | 41 | deploy: 42 | name: Deploy 43 | needs: build 44 | runs-on: ubuntu-latest 45 | if: github.ref == 'refs/heads/main' 46 | 47 | steps: 48 | - name: Download artifact 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: production-files 52 | path: ./docs/.vitepress/dist 53 | 54 | - name: Deploy to GitHub Pages 55 | uses: peaceiris/actions-gh-pages@v3 56 | with: 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | publish_dir: ./docs/.vitepress/dist 59 | -------------------------------------------------------------------------------- /src/devtools/devtoolsBuilder.ts: -------------------------------------------------------------------------------- 1 | import type { UnwrapRef } from 'vue' 2 | 3 | import type { 4 | Field, 5 | Form, 6 | } from '../types' 7 | 8 | export function buildFormState(form: UnwrapRef>) { 9 | return { 10 | 'Form state': [ 11 | { 12 | key: 'state', 13 | value: form.state, 14 | }, 15 | { 16 | key: 'errors', 17 | value: form.errors, 18 | }, 19 | { 20 | key: 'isDirty', 21 | value: form.isDirty, 22 | }, 23 | { 24 | key: 'hasAttemptedToSubmit', 25 | value: form.hasAttemptedToSubmit, 26 | }, 27 | { 28 | key: 'isSubmitting', 29 | value: form.isSubmitting, 30 | }, 31 | { 32 | key: 'isValid', 33 | value: form.isValid, 34 | }, 35 | ], 36 | } 37 | } 38 | 39 | export function buildFieldState(field: UnwrapRef>) { 40 | return { 41 | 'Field state': [ 42 | { 43 | key: 'value', 44 | value: field.modelValue, 45 | }, 46 | { 47 | key: 'path', 48 | value: field._path, 49 | }, 50 | { 51 | key: 'errors', 52 | value: field.errors, 53 | }, 54 | { 55 | key: 'isChanged', 56 | value: field.isChanged, 57 | }, 58 | { 59 | key: 'isDirty', 60 | value: field.isDirty, 61 | }, 62 | { 63 | key: 'isTouched', 64 | value: field.isTouched, 65 | }, 66 | ], 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/HomeTeam.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Our Team 50 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 70 | -------------------------------------------------------------------------------- /docs/public/assets/ts.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "d9d61237", 3 | "browserHash": "3b097c5c", 4 | "optimized": { 5 | "vue": { 6 | "src": "../../../../node_modules/.pnpm/vue@3.4.21_typescript@5.7.2/node_modules/vue/dist/vue.runtime.esm-browser.js", 7 | "file": "vue.js", 8 | "fileHash": "33e64f16", 9 | "needsInterop": false 10 | }, 11 | "vitepress > @vue/devtools-api": { 12 | "src": "../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/index.js", 13 | "file": "vitepress___@vue_devtools-api.js", 14 | "fileHash": "ed163542", 15 | "needsInterop": false 16 | }, 17 | "vitepress > @vueuse/integrations/useFocusTrap": { 18 | "src": "../../../../node_modules/.pnpm/@vueuse+integrations@10.3.0_focus-trap@7.5.2_vue@3.4.21_typescript@5.7.2_/node_modules/@vueuse/integrations/useFocusTrap.mjs", 19 | "file": "vitepress___@vueuse_integrations_useFocusTrap.js", 20 | "fileHash": "10e58b92", 21 | "needsInterop": false 22 | }, 23 | "vitepress > mark.js/src/vanilla.js": { 24 | "src": "../../../../node_modules/.pnpm/mark.js@8.11.1/node_modules/mark.js/src/vanilla.js", 25 | "file": "vitepress___mark__js_src_vanilla__js.js", 26 | "fileHash": "9e7f850a", 27 | "needsInterop": false 28 | }, 29 | "vitepress > minisearch": { 30 | "src": "../../../../node_modules/.pnpm/minisearch@6.1.0/node_modules/minisearch/dist/es/index.js", 31 | "file": "vitepress___minisearch.js", 32 | "fileHash": "30a16717", 33 | "needsInterop": false 34 | } 35 | }, 36 | "chunks": { 37 | "chunk-JI5EUS42": { 38 | "file": "chunk-JI5EUS42.js" 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Form 'n Go" 7 | text: "Fastest form development" 8 | tagline: A juicy, fully typed, standard schema compliant, light weight, Vue form library 9 | image: 10 | src: assets/mango_no_shadow.svg 11 | alt: Formango 12 | 13 | actions: 14 | - theme: brand 15 | text: Getting started 16 | link: /guide/getting-started 17 | - theme: alt 18 | text: API documentation 19 | link: /api/useForm 20 | 21 | features: 22 | - title: Headless 23 | details: Use together with any component library or your own custom UI. 24 | icon: 25 | src: /assets/headless.png 26 | - title: Type Safe 27 | details: Built from the ground up with typescript support. 28 | icon: 29 | src: /assets/ts.svg 30 | - title: Standard schema 31 | details: Standard schema spec compliant, supporting Zod, Valibot and ArkType or any other schema library following the spec. 32 | icon: 33 | src: /assets/zod.svg 34 | - title: I18n 35 | details: Using the schema library and vue-i18n, the error messages are fully translatable. 36 | icon: 37 | src: /assets/world.svg 38 | - title: Devtools 39 | details: Built-in Vue devtool support. 40 | icon: 41 | src: /assets/devtools.svg 42 | - title: Fast Development 43 | details: Simple API to develop forms at a never seen before speed. 44 | icon: 45 | src: /assets/rocket.svg 46 | 47 | --- 48 | 49 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/public/assets/world.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/examples/external-errors.md: -------------------------------------------------------------------------------- 1 | # Subforms 2 | 3 | ## How to 4 | 5 | Using the addErrors function on the Form object, you can add external errors to the form. 6 | 7 | ## Example 8 | 9 | ::: code-group 10 | 11 | ```vue [LoginForm.vue] 12 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ``` 63 | 64 | ```ts [loginForm.model.ts] 65 | import { z } from 'zod' 66 | 67 | export const loginFormSchema = z.object({ 68 | email: z.string().email(), 69 | password: z.string().min(8), 70 | }) 71 | 72 | export type LoginForm = z.infer 73 | ``` 74 | 75 | ::: -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Formango 2 | 3 | ## 3.0.0 4 | 5 | ### Major changes 6 | 7 | - Form is now the root object that is returned by useForm, instead { form, ... } 8 | - Added onSubmitError callback to useForm, which passes the data and errors to the callback function 9 | - Added onSubmit callback to useForm, which passes the data to the callback function 10 | - Added rawErrors to useForm, which is an array of objects with a message and path, which are the raw errors from StandardSchemaV1 11 | - Added formatErrorsToZodFormattedError to format the errors to ZodFormattedError, which can handle both FormattedError and StandardSchemaV1 Issues 12 | - Added reset function to useForm, which resets the form to the initial state 13 | - Removed the onSubmitFormError callback from useForm, as it is now handled by onSubmitError 14 | - Removed the onSubmitForm callback from useForm, as it is now handled by onSubmit 15 | - Refactored internal code to use StandardSchemaV1 instead of Zod 16 | - Refactored internal code to use Ref and ComputedRef instead of Reactive 17 | - Refactored errors to custom formatting, that is an array of objects with a message and path 18 | 19 | ## 2.0.34 20 | 21 | ### Minor changes 22 | 23 | - Allow for nested object values to be nullable in initialState 24 | 25 | ## 2.0.25 26 | 27 | ### Major changes 28 | 29 | - Reimplemented vue devtools 30 | 31 | ## 2.0.0 32 | 33 | ### Major Changes 34 | 35 | - Implemented Register function on the Field object and the FieldArray object, meaning you create subforms more easily. Visit the [documentation](https://wisemen-digital.github.io/vue-formango/examples/subforms) for an example. 36 | - Changed the useForm API to use a single object. For future updates / features the API doesn't need breaking changes this way. 37 | - Added tests. 38 | - Refactored library based on tests. 39 | - Updated docs for 2.0 and added more examples. 40 | -------------------------------------------------------------------------------- /docs/public/assets/zod.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | zod 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/useFormSetValue.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | objectSchema, 11 | } from './testUtils' 12 | 13 | describe('set a value of a field', () => { 14 | it('should set a value of a field with `onUpdate.modelValue.value`', () => { 15 | const form = useForm({ 16 | schema: basicSchema, 17 | onSubmit: (data) => { 18 | return data 19 | }, 20 | }) 21 | 22 | const name = form.register('name') 23 | 24 | name['onUpdate:modelValue']('John') 25 | 26 | expect(name.modelValue.value).toBe('John') 27 | 28 | expect(form.state.value).toEqual({ name: 'John' }) 29 | }) 30 | 31 | it('should set a value of a field with `setValue`', () => { 32 | const form = useForm({ 33 | schema: basicSchema, 34 | onSubmit: (data) => { 35 | return data 36 | }, 37 | }) 38 | 39 | const name = form.register('name') 40 | 41 | name.setValue('John') 42 | 43 | expect(name.modelValue.value).toBe('John') 44 | 45 | expect(form.state.value).toEqual({ name: 'John' }) 46 | }) 47 | 48 | it('should set a value of a field with `form.setValues`', () => { 49 | const form = useForm({ 50 | schema: basicSchema, 51 | onSubmit: (data) => { 52 | return data 53 | }, 54 | }) 55 | 56 | const name = form.register('name') 57 | 58 | form.setValues({ name: 'John' }) 59 | 60 | expect(name.modelValue.value).toBe('John') 61 | 62 | expect(form.state.value).toEqual({ name: 'John' }) 63 | }) 64 | 65 | it('should set a nested value of a field with `form.setValues`', () => { 66 | const form = useForm({ 67 | schema: objectSchema, 68 | onSubmit: (data) => { 69 | return data 70 | }, 71 | }) 72 | 73 | const b = form.register('a.b') 74 | 75 | form.setValues({ a: { b: 'John' } }) 76 | 77 | expect(b.modelValue.value).toBe('John') 78 | 79 | expect(form.state.value).toEqual({ a: { b: 'John' } }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2 20 | with: 21 | version: 9 22 | 23 | - name: Set node 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 22.x 27 | cache: pnpm 28 | 29 | - name: Setup 30 | run: npm i -g @antfu/ni 31 | 32 | - name: Install 33 | run: nci 34 | 35 | - name: Lint 36 | run: nr lint 37 | 38 | typecheck: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v2 45 | with: 46 | version: 9 47 | 48 | - name: Set node 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version: 22.x 52 | cache: pnpm 53 | 54 | - name: Setup 55 | run: npm i -g @antfu/ni 56 | 57 | - name: Install 58 | run: nci 59 | 60 | - name: Typecheck 61 | run: nr typecheck 62 | 63 | test: 64 | runs-on: ${{ matrix.os }} 65 | 66 | strategy: 67 | matrix: 68 | node: [22.x] 69 | os: [ubuntu-latest, windows-latest, macos-latest] 70 | fail-fast: false 71 | 72 | steps: 73 | - uses: actions/checkout@v3 74 | 75 | - name: Install pnpm 76 | uses: pnpm/action-setup@v2 77 | with: 78 | version: 9 79 | 80 | - name: Set node ${{ matrix.node }} 81 | uses: actions/setup-node@v3 82 | with: 83 | node-version: ${{ matrix.node }} 84 | cache: pnpm 85 | 86 | - name: Setup 87 | run: npm i -g @antfu/ni 88 | 89 | - name: Install 90 | run: nci 91 | 92 | - name: Build 93 | run: nr build 94 | 95 | - name: Test 96 | run: nr test 97 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Formango 2 | 3 | ## 3.0.0 4 | 5 | ### Major changes 6 | 7 | - Form is now the root object that is returned by useForm, instead { form, ... } 8 | - FieldArray type now requires a single generic to be passed, instead of an array of it 9 | - FieldArray now returns a type with generic never if passed a non-array path 10 | - Added onSubmitError callback to useForm, which passes the data and errors to the callback function 11 | - Added onSubmit callback to useForm, which passes the data to the callback function 12 | - Added rawErrors to useForm, which is an array of objects with a message and path, which are the raw errors from StandardSchemaV1 13 | - Added formatErrorsToZodFormattedError to format the errors to ZodFormattedError, which can handle both FormattedError and StandardSchemaV1 Issues 14 | - Added reset function to useForm, which resets the form to the initial state 15 | - Removed the onSubmitFormError callback from useForm, as it is now handled by onSubmitError 16 | - Removed the onSubmitForm callback from useForm, as it is now handled by onSubmit 17 | - Refactored internal code to use StandardSchemaV1 instead of Zod, which means Zod, ArkType and Valibot are now supported 18 | - Refactored internal code to use Ref and ComputedRef instead of Reactive 19 | - Refactored errors to custom formatting, that is an array of objects with a message and path 20 | 21 | 22 | ## 2.0.34 23 | 24 | ### Minor changes 25 | 26 | - Allow for nested object values to be nullable in initialState 27 | 28 | ## 2.0.25 29 | 30 | ### Major changes 31 | 32 | - Reimplemented vue devtools 33 | 34 | ## 2.0.0 35 | 36 | ### Major Changes 37 | 38 | - Implemented Register function on the Field object and the FieldArray object, meaning you create subforms more easily. Visit the [documentation](https://wisemen-digital.github.io/vue-formango/examples/subforms) for an example. 39 | - Changed the useForm API to use a single object. For future updates / features the API doesn't need breaking changes this way. 40 | - Added tests. 41 | - Refactored library based on tests. 42 | - Updated docs for 2.0 and added more examples. 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formango", 3 | "type": "module", 4 | "version": "3.2.0-beta.1", 5 | "description": "", 6 | "author": "Wouter Laermans ", 7 | "license": "MIT", 8 | "keywords": [ 9 | "vue", 10 | "vue3", 11 | "form", 12 | "form validation", 13 | "validation", 14 | "zod", 15 | "schema", 16 | "formango" 17 | ], 18 | "sideEffects": false, 19 | "main": "./dist/index.mjs", 20 | "module": "./dist/index.mjs", 21 | "types": "./dist/index.d.ts", 22 | "files": [ 23 | "dist" 24 | ], 25 | "scripts": { 26 | "build": "unbuild", 27 | "dev": "unbuild --stub", 28 | "lint": "eslint .", 29 | "prepublishOnly": "nr build", 30 | "release": "bumpp && npm publish", 31 | "start": "esno src/index.ts", 32 | "test": "vitest", 33 | "pub:beta": "pnpm publish --no-git-checks --access public --tag beta", 34 | "coverage": "vitest run --coverage", 35 | "typecheck": "tsc --noEmit", 36 | "docs:dev": "vitepress dev docs", 37 | "docs:build": "vitepress build docs", 38 | "docs:preview": "vitepress preview docs" 39 | }, 40 | "peerDependencies": { 41 | "vue": "^3.2.47", 42 | "zod": "^3.24.1" 43 | }, 44 | "dependencies": { 45 | "@vue/devtools-api": "^6.6.4", 46 | "clone-deep": "^4.0.1" 47 | 48 | }, 49 | "devDependencies": { 50 | "@antfu/ni": "^24.3.0", 51 | "@antfu/utils": "^9.2.0", 52 | "@standard-schema/spec": "1.0.0", 53 | "@types/clone-deep": "^4.0.4", 54 | "@types/node": "^22.14.1", 55 | "@vitest/coverage-v8": "^3.1.1", 56 | "@wisemen/eslint-config-vue": "1.2.0-beta.3", 57 | "bumpp": "^10.1.0", 58 | "eslint": "^9.24.0", 59 | "esno": "^4.8.0", 60 | "lint-staged": "^15.5.1", 61 | "pnpm": "^10.8.0", 62 | "rimraf": "^6.0.1", 63 | "simple-git-hooks": "^2.12.1", 64 | "tailwindcss": "^4.1.3", 65 | "typescript": "^5.8.3", 66 | "unbuild": "^3.5.0", 67 | "vite": "^6.2.6", 68 | "vitepress": "1.6.3", 69 | "vitest": "^3.1.1", 70 | "vue": "^3.5.13" 71 | }, 72 | "simple-git-hooks": { 73 | "pre-commit": "pnpm lint-staged" 74 | }, 75 | "lint-staged": { 76 | "*": "eslint --fix" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/useFormValueSync.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | objectArraySchema, 10 | objectSchema, 11 | } from './testUtils' 12 | 13 | describe('modelValue and value stay in sync', () => { 14 | it('should update both when setting value', () => { 15 | const form = useForm({ 16 | schema: objectSchema, 17 | onSubmit: (data) => { 18 | return data 19 | }, 20 | }) 21 | 22 | const a = form.register('a') 23 | const b = form.register('a.b') 24 | 25 | a.setValue({ 26 | b: 'John', 27 | bObj: { c: '123' }, 28 | }) 29 | 30 | expect(a.modelValue.value).toEqual({ 31 | b: 'John', 32 | bObj: { c: '123' }, 33 | }) 34 | 35 | expect(a.value.value).toEqual({ 36 | b: 'John', 37 | bObj: { c: '123' }, 38 | }) 39 | 40 | expect(b.modelValue.value).toBe('John') 41 | expect(b.value.value).toBe('John') 42 | }) 43 | 44 | it('should update both when setting initial value', () => { 45 | const form = useForm({ 46 | initialState: { 47 | a: { 48 | b: 'John', 49 | bObj: { c: '123' }, 50 | }, 51 | }, 52 | schema: objectSchema, 53 | onSubmit: (data) => { 54 | return data 55 | }, 56 | }) 57 | 58 | const a = form.register('a') 59 | const b = form.register('a.b') 60 | 61 | expect(a.modelValue.value).toEqual({ 62 | b: 'John', 63 | bObj: { c: '123' }, 64 | }) 65 | 66 | expect(a.value.value).toEqual({ 67 | b: 'John', 68 | bObj: { c: '123' }, 69 | }) 70 | 71 | expect(b.modelValue.value).toBe('John') 72 | expect(b.value.value).toBe('John') 73 | }) 74 | 75 | it('should update array when setting value', () => { 76 | const form = useForm({ 77 | schema: objectArraySchema, 78 | onSubmit: (data) => { 79 | return data 80 | }, 81 | }) 82 | 83 | const array = form.registerArray('array') 84 | 85 | array.setValue([ 86 | { name: 'John' }, 87 | ]) 88 | 89 | expect(array.modelValue.value).toEqual([ 90 | { name: 'John' }, 91 | ]) 92 | expect(array.value.value).toEqual([ 93 | { name: 'John' }, 94 | ]) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/useFormReset.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | objectArraySchema, 11 | sleep, 12 | } from './testUtils' 13 | 14 | describe('reset form', () => { 15 | it('should reset the form with initial state', async () => { 16 | const form = useForm({ 17 | initialState: { name: 'John' }, 18 | schema: basicSchema, 19 | onSubmit: (data) => { 20 | return data 21 | }, 22 | }) 23 | 24 | const name = form.register('name') 25 | 26 | await sleep(0) 27 | 28 | expect(name.modelValue.value).toBe('John') 29 | name.setValue('Doe') 30 | 31 | await sleep(0) 32 | 33 | expect(name.modelValue.value).toBe('Doe') 34 | form.reset() 35 | 36 | await sleep(0) 37 | 38 | expect(name.modelValue.value).toBe('John') 39 | }) 40 | 41 | it('should throw error when resetting the form without initial state', () => { 42 | const form = useForm({ 43 | schema: basicSchema, 44 | onSubmit: (data) => { 45 | return data 46 | }, 47 | 48 | }) 49 | 50 | expect(() => form.reset()).toThrow('In order to reset the form, you need to provide an initial state') 51 | }) 52 | 53 | it('should reset the isTouched state of all fields', async () => { 54 | const form = useForm({ 55 | initialState: { 56 | array: [ 57 | { name: null }, 58 | { name: null }, 59 | ], 60 | }, 61 | schema: objectArraySchema, 62 | onSubmit: (data) => { 63 | return data 64 | }, 65 | }) 66 | 67 | const firstName = form.register('array.0.name') 68 | const secondName = form.register('array.1.name') 69 | 70 | firstName.onBlur() 71 | 72 | await sleep(0) 73 | 74 | expect(firstName.isTouched.value).toBeTruthy() 75 | expect(secondName.isTouched.value).toBeFalsy() 76 | 77 | secondName.onBlur() 78 | 79 | await sleep(0) 80 | 81 | expect(firstName.isTouched.value).toBeTruthy() 82 | expect(secondName.isTouched.value).toBeTruthy() 83 | 84 | form.reset() 85 | 86 | await sleep(0) 87 | 88 | expect(firstName.isTouched.value).toBeFalsy() 89 | expect(secondName.isTouched.value).toBeFalsy() 90 | 91 | secondName.onBlur() 92 | 93 | await sleep(0) 94 | 95 | expect(firstName.isTouched.value).toBeFalsy() 96 | expect(secondName.isTouched.value).toBeTruthy() 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/lib/formatErrors.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ZodFormattedError, 3 | ZodIssue, 4 | } from 'zod' 5 | 6 | import type { 7 | FormattedError, 8 | StandardSchemaV1, 9 | } from '../types' 10 | 11 | function issueMapper(issue: FormattedError | StandardSchemaV1.Issue) { 12 | return issue.message 13 | } 14 | 15 | function isZodIssue(error: any): error is ZodIssue { 16 | return error.code !== undefined 17 | } 18 | 19 | type SomeIssues = FormattedError[] | readonly StandardSchemaV1.Issue[] 20 | type SomeIssue = FormattedError | StandardSchemaV1.Issue 21 | 22 | function getNormalizedPathArray(issue: SomeIssue): string[] { 23 | if (typeof issue.path === 'object') { 24 | return issue.path 25 | ?.map((item) => (typeof item === 'object' ? item.key : item)) as string[] 26 | } 27 | 28 | return issue.path as unknown as string[] 29 | } 30 | 31 | export function formatErrorsToZodFormattedError(issues: SomeIssues): ZodFormattedError { 32 | const fieldErrors: ZodFormattedError = { _errors: [] } as any 33 | 34 | function processIssue(issue: SomeIssue) { 35 | // Handle zod only issue types 36 | if (isZodIssue(issue)) { 37 | if (issue.code === 'invalid_union') { 38 | issue.unionErrors.map(processIssue) 39 | 40 | return 41 | } 42 | if (issue.code === 'invalid_return_type') { 43 | processIssue(issue.returnTypeError) 44 | 45 | return 46 | } 47 | if (issue.code === 'invalid_arguments') { 48 | processIssue(issue.argumentsError) 49 | 50 | return 51 | } 52 | } 53 | 54 | // Issue without path gets added to the root 55 | if (issue.path == null || issue.path?.length === 0) { 56 | fieldErrors._errors.push(issueMapper(issue)) 57 | 58 | return 59 | } 60 | 61 | // Issue with path gets added to the correct field 62 | 63 | const normalizedPath = getNormalizedPathArray(issue) 64 | 65 | let curr: any = fieldErrors 66 | let i = 0 67 | 68 | while (i < normalizedPath.length) { 69 | const el = normalizedPath[i] 70 | const terminal = i === normalizedPath.length - 1 71 | 72 | if (!terminal) { 73 | curr[el] = curr[el] || { _errors: [] } 74 | } 75 | else { 76 | curr[el] = curr[el] || { _errors: [] } 77 | curr[el]._errors.push(issueMapper(issue)) 78 | } 79 | 80 | curr = curr[el] 81 | i++ 82 | } 83 | } 84 | 85 | for (const issue of issues) { 86 | processIssue(issue) 87 | } 88 | 89 | return fieldErrors 90 | } 91 | -------------------------------------------------------------------------------- /test/useFormBlurAll.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src' 8 | import { 9 | nestedArraySchema, 10 | objectSchema, 11 | } from './testUtils' 12 | 13 | describe('when calling blurAll', () => { 14 | it('on an array, it should blur all its children and nested objects', () => { 15 | const form = useForm({ 16 | schema: nestedArraySchema, 17 | onSubmit: (data) => { 18 | return data 19 | }, 20 | }) 21 | 22 | const users = form.registerArray('users') 23 | const room = users.registerArray('0') 24 | const roomFive = room.register('5') 25 | const name = roomFive.register('name') 26 | 27 | users.blurAll() 28 | 29 | expect(roomFive.isTouched.value).toBeTruthy() 30 | expect(name.isTouched.value).toBeTruthy() 31 | }) 32 | 33 | it('on a nested object, it should not blur its peers', () => { 34 | const form = useForm({ 35 | schema: objectSchema, 36 | onSubmit: (data) => { 37 | return data 38 | }, 39 | }) 40 | 41 | const a = form.register('a') 42 | const bObject = form.register('a.bObj') 43 | const b = a.register('b') 44 | const c = bObject.register('c') 45 | 46 | b.blurAll() 47 | 48 | expect(a.isTouched.value).toBeTruthy() 49 | expect(b.isTouched.value).toBeTruthy() 50 | expect(c.isTouched.value).toBeFalsy() 51 | expect(bObject.isTouched.value).toBeFalsy() 52 | }) 53 | 54 | it('on a nested object, it should blur its children', () => { 55 | const form = useForm({ 56 | schema: objectSchema, 57 | onSubmit: (data) => { 58 | return data 59 | }, 60 | }) 61 | 62 | const a = form.register('a') 63 | const bObject = form.register('a.bObj') 64 | const b = a.register('b') 65 | const c = bObject.register('c') 66 | 67 | a.blurAll() 68 | 69 | expect(a.isTouched.value).toBeTruthy() 70 | expect(b.isTouched.value).toBeTruthy() 71 | expect(c.isTouched.value).toBeTruthy() 72 | expect(bObject.isTouched.value).toBeTruthy() 73 | }) 74 | 75 | it('on a nested object, it should blur its children of a field', () => { 76 | const form = useForm({ 77 | schema: objectSchema, 78 | onSubmit: (data) => { 79 | return data 80 | }, 81 | }) 82 | 83 | const a = form.register('a') 84 | const bObject = form.register('a.bObj') 85 | const b = a.register('b') 86 | const c = bObject.register('c') 87 | 88 | bObject.blurAll() 89 | 90 | expect(a.isTouched.value).toBeTruthy() 91 | expect(b.isTouched.value).toBeFalsy() 92 | expect(c.isTouched.value).toBeTruthy() 93 | expect(bObject.isTouched.value).toBeTruthy() 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * -------------------------------------------------------------------------- */ 9 | 10 | :root { 11 | --vp-c-brand: #f37621; 12 | --vp-c-brand-light: #f8b506; 13 | --vp-c-brand-lighter: #fff4d3; 14 | --vp-c-brand-lightest: #ffffff; 15 | --vp-c-brand-dark: #027d3a; 16 | --vp-c-brand-darker: #000000; 17 | --vp-c-brand-dimm: rgba(2, 125, 58, 0.15); 18 | } 19 | 20 | /** 21 | * Component: Button 22 | * -------------------------------------------------------------------------- */ 23 | 24 | :root { 25 | --vp-button-brand-border: var(--vp-c-brand-light); 26 | --vp-button-brand-text: var(--vp-c-white); 27 | --vp-button-brand-bg: var(--vp-c-brand); 28 | --vp-button-brand-hover-border: var(--vp-c-brand-light); 29 | --vp-button-brand-hover-text: var(--vp-c-white); 30 | --vp-button-brand-hover-bg: var(--vp-c-brand-light); 31 | --vp-button-brand-active-border: var(--vp-c-brand-light); 32 | --vp-button-brand-active-text: var(--vp-c-white); 33 | --vp-button-brand-active-bg: var(--vp-button-brand-bg); 34 | } 35 | 36 | /** 37 | * Component: Home 38 | * -------------------------------------------------------------------------- */ 39 | 40 | :root { 41 | --vp-home-hero-name-color: transparent; 42 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #027d3a 5%, #f37621); 43 | 44 | --vp-home-hero-image-background-image: linear-gradient(-10deg, #f37621 5%, #f8b506 75%, #027d3a 20%); 45 | --vp-home-hero-image-filter: blur(40px); 46 | } 47 | 48 | @media (min-width: 640px) { 49 | :root { 50 | --vp-home-hero-image-filter: blur(56px); 51 | } 52 | } 53 | 54 | @media (min-width: 960px) { 55 | :root { 56 | --vp-home-hero-image-filter: blur(72px); 57 | } 58 | } 59 | 60 | /** 61 | * Component: Custom Block 62 | * -------------------------------------------------------------------------- */ 63 | 64 | :root { 65 | --vp-custom-block-tip-border: var(--vp-c-brand); 66 | --vp-custom-block-tip-text: var(--vp-c-brand-darker); 67 | --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); 68 | } 69 | 70 | .dark { 71 | --vp-custom-block-tip-border: var(--vp-c-brand); 72 | --vp-custom-block-tip-text: var(--vp-c-brand-lightest); 73 | --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); 74 | } 75 | 76 | /** 77 | * Component: Algolia 78 | * -------------------------------------------------------------------------- */ 79 | 80 | .DocSearch { 81 | --docsearch-primary-color: var(--vp-c-brand) !important; 82 | } 83 | 84 | .box .VPImage { 85 | padding: 8px; 86 | background-color: var(--vp-c-brand-dimm); 87 | border-radius: 8px; 88 | } 89 | -------------------------------------------------------------------------------- /docs/examples/subforms.md: -------------------------------------------------------------------------------- 1 | # Subforms 2 | 3 | ## How to 4 | 5 | Using the Field register function, it is possible to create reusable subforms that manage their own state. 6 | 7 | ## Example 8 | 9 | ::: code-group 10 | 11 | ```ts [userForm.model.ts] 12 | import { z } from 'zod' 13 | 14 | import { addressSchema } from './address.model' 15 | 16 | export const userSchema = z.object({ 17 | email: z.string().email(), 18 | shippingAddress: addressSchema, 19 | invoiceAddress: addressSchema, 20 | }) 21 | 22 | export type User = z.infer 23 | ``` 24 | 25 | ```ts [address.model.ts] 26 | import { z } from 'zod' 27 | 28 | export const addressSchema = z.object({ 29 | street: z.string(), 30 | postalCode: z.string(), 31 | city: z.string(), 32 | country: z.string(), 33 | }) 34 | 35 | export type Address = z.infer 36 | ``` 37 | 38 | ```vue [UserForm.vue] 39 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 72 | 73 | 74 | ``` 75 | 76 | ```vue [AddressForm.vue] 77 | 95 | 96 | 97 | 98 | 99 | {{ label }} 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ``` -------------------------------------------------------------------------------- /test/useFormUnregister.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | import { z } from 'zod' 7 | 8 | import { useForm } from '../src/lib/useForm' 9 | import { 10 | basicArraySchema, 11 | basicSchema, 12 | } from './testUtils' 13 | 14 | describe('unregister a field or fieldArray', () => { 15 | it('should unregister a field', () => { 16 | const form = useForm({ 17 | schema: basicSchema, 18 | onSubmit: (data) => { 19 | return data 20 | }, 21 | }) 22 | 23 | form.register('name', 'John') 24 | form.unregister('name') 25 | 26 | expect(form.state.value).toEqual({}) 27 | }) 28 | 29 | it('should register a field on the same index after unregistering', () => { 30 | const form = useForm({ 31 | schema: basicArraySchema, 32 | onSubmit: (data) => { 33 | return data 34 | }, 35 | }) 36 | 37 | const array = form.registerArray('array') 38 | 39 | array.append('John') 40 | array.register('0') 41 | 42 | array.remove(0) 43 | 44 | array.append('Doe') 45 | 46 | array.register('0') 47 | 48 | expect(form.state.value).toEqual({ 49 | array: [ 50 | 'Doe', 51 | ], 52 | }) 53 | }) 54 | 55 | it('should unregister an array index', () => { 56 | const form = useForm({ 57 | schema: basicArraySchema, 58 | onSubmit: (data) => { 59 | return data 60 | }, 61 | }) 62 | 63 | const array = form.registerArray('array') 64 | 65 | array.append('John') 66 | 67 | array.remove(0) 68 | 69 | expect(form.state.value).toEqual({ array: [] }) 70 | 71 | array.append('Doe') 72 | 73 | expect(form.state.value).toEqual({ 74 | array: [ 75 | 'Doe', 76 | ], 77 | }) 78 | 79 | form.unregister('array.0') 80 | }) 81 | 82 | it('should unregister an array index with a subfield', () => { 83 | const form = useForm({ 84 | schema: z.object({ questions: z.object({ choices: z.object({ text: z.string() }).array() }).array() }), 85 | onSubmit: (data) => { 86 | return data 87 | }, 88 | }) 89 | 90 | const questions = form.registerArray('questions') 91 | 92 | questions.append() 93 | 94 | const question0 = form.register('questions.0') 95 | 96 | const choices = question0.registerArray('choices') 97 | 98 | choices.append() 99 | 100 | const choice = form.register('questions.0.choices.0') 101 | 102 | choice.register('text') 103 | 104 | choices.remove(0) 105 | 106 | // const choices = form.registerArray('choices') 107 | 108 | // choices.append() 109 | 110 | // const choice = form.register('choices.0') 111 | 112 | // choice.register('text') 113 | 114 | // choices.remove(0) 115 | 116 | // expect(form.state.value).toEqual({ 117 | // choices: [], 118 | // }) 119 | 120 | expect(choices.modelValue.value).toEqual([]) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /test/useFormIsDirty.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { basicSchema } from './testUtils' 9 | 10 | describe('isDirty', () => { 11 | it('should be false by default', () => { 12 | const form = useForm({ 13 | schema: basicSchema, 14 | onSubmit: (data) => { 15 | return data 16 | }, 17 | }) 18 | 19 | expect(form.isDirty.value).toBeFalsy() 20 | }) 21 | 22 | it('should be false when a default state is provided', () => { 23 | const form = useForm({ 24 | initialState: { name: 'John' }, 25 | schema: basicSchema, 26 | onSubmit: (data) => { 27 | return data 28 | }, 29 | }) 30 | 31 | expect(form.isDirty.value).toBeFalsy() 32 | }) 33 | 34 | it('should be false when a field is registered with a default value', () => { 35 | const form = useForm({ 36 | schema: basicSchema, 37 | onSubmit: (data) => { 38 | return data 39 | }, 40 | }) 41 | 42 | const name = form.register('name', 'John') 43 | 44 | expect(name.isDirty.value).toBeFalsy() 45 | expect(form.isDirty.value).toBeFalsy() 46 | }) 47 | 48 | it('should be true when a field is changed', () => { 49 | const form = useForm({ 50 | schema: basicSchema, 51 | onSubmit: (data) => { 52 | return data 53 | }, 54 | }) 55 | 56 | const name = form.register('name') 57 | 58 | name.setValue('John') 59 | 60 | expect(name.isDirty.value).toBeTruthy() 61 | expect(form.isDirty.value).toBeTruthy() 62 | }) 63 | 64 | it('should be false when a field is changed back to its initial value', () => { 65 | const form = useForm({ 66 | schema: basicSchema, 67 | onSubmit: (data) => { 68 | return data 69 | }, 70 | }) 71 | 72 | const name = form.register('name') 73 | 74 | name.setValue('John') 75 | name.setValue(null) 76 | 77 | expect(name.isDirty.value).toBeFalsy() 78 | expect(form.isDirty.value).toBeFalsy() 79 | }) 80 | 81 | it('should be false when a field is changed back to its initial value with default value', () => { 82 | const form = useForm({ 83 | schema: basicSchema, 84 | onSubmit: (data) => { 85 | return data 86 | }, 87 | }) 88 | 89 | const name = form.register('name', 'John') 90 | 91 | name.setValue('Joe') 92 | name.setValue('John') 93 | 94 | expect(name.isDirty.value).toBeFalsy() 95 | expect(form.isDirty.value).toBeFalsy() 96 | }) 97 | 98 | it('should be false after the form has been submitted', async () => { 99 | const form = useForm({ 100 | schema: basicSchema, 101 | onSubmit: (data) => { 102 | return data 103 | }, 104 | 105 | }) 106 | 107 | const name = form.register('name') 108 | 109 | name.setValue('John') 110 | 111 | await form.submit() 112 | 113 | expect(name.isDirty.value).toBeFalsy() 114 | expect(form.isDirty.value).toBeFalsy() 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/useFormNestedArray.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src' 8 | import { nestedArraySchema } from './testUtils' 9 | 10 | describe('when appending and registering a nested array', () => { 11 | it('should fill up the data with undefined if you register an index that does not exist', () => { 12 | const form = useForm({ 13 | schema: nestedArraySchema, 14 | onSubmit: (data) => { 15 | return data 16 | }, 17 | }) 18 | 19 | const users = form.registerArray('users') 20 | const room = users.registerArray('0') 21 | 22 | expect(room.modelValue.value).toEqual([]) 23 | 24 | room.register('5') 25 | 26 | // Undefined is when a field has not been registered yet 27 | expect(room.modelValue.value).toEqual([ 28 | undefined, 29 | undefined, 30 | undefined, 31 | undefined, 32 | undefined, 33 | null, 34 | ]) 35 | }) 36 | 37 | it('should fill up the data with null if you register an index that does not exist', () => { 38 | const form = useForm({ 39 | schema: nestedArraySchema, 40 | onSubmit: (data) => { 41 | return data 42 | }, 43 | }) 44 | 45 | const users = form.registerArray('users') 46 | const room = users.registerArray('0') 47 | 48 | expect(room.modelValue.value).toEqual([]) 49 | 50 | room.register('0') 51 | room.register('1') 52 | room.register('2') 53 | room.register('3') 54 | 55 | expect(room.modelValue.value).toEqual([ 56 | null, 57 | null, 58 | null, 59 | null, 60 | ]) 61 | }) 62 | 63 | it('the array should fill up with the correct data', () => { 64 | const form = useForm({ 65 | schema: nestedArraySchema, 66 | onSubmit: (data) => { 67 | return data 68 | }, 69 | }) 70 | 71 | const users = form.registerArray('users') 72 | const room = users.registerArray('0') 73 | 74 | expect(room.modelValue.value).toEqual([]) 75 | 76 | room.register('2') 77 | 78 | // Undefined is when a field has not been registered yet 79 | expect(room.modelValue.value).toEqual([ 80 | undefined, 81 | undefined, 82 | null, 83 | ]) 84 | 85 | room.append({ name: 'Peter' }) 86 | 87 | // Append does append at the end of the array 88 | expect(room.modelValue.value).toEqual([ 89 | undefined, 90 | undefined, 91 | null, 92 | { name: 'Peter' }, 93 | ]) 94 | 95 | room.register('1', { name: 'Doe' }) 96 | 97 | // Retroactively registering a field should put the data in the correct spot 98 | expect(room.modelValue.value).toEqual([ 99 | undefined, 100 | { name: 'Doe' }, 101 | null, 102 | { name: 'Peter' }, 103 | ]) 104 | expect(form.state.value).toEqual({ 105 | users: [ 106 | [ 107 | undefined, 108 | { name: 'Doe' }, 109 | null, 110 | { name: 'Peter' }, 111 | ], 112 | ], 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /test/wiserTemplateSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export enum TemplateType { 4 | INDIVIDUAL = 'individual', 5 | PEER_REVIEW = 'peer_review', 6 | } 7 | 8 | export const templateFormTypeEnum = z.nativeEnum(TemplateType) 9 | export const choiceUuidSchema = z.string().uuid().brand('ChoiceUuid') 10 | export const questionUuidSchema = z.string().uuid().brand('QuestionUuid') 11 | 12 | const baseTemplateFormQuestionSchema = z.object({ 13 | uuid: questionUuidSchema.nullable(), 14 | title: z.string().min(1), 15 | hasExtraInformation: z.boolean(), 16 | isRequired: z.boolean().default(true), 17 | description: z.string().nullable(), 18 | }) 19 | 20 | export const templateChoiceSchema = z.object({ 21 | uuid: choiceUuidSchema.nullable(), 22 | sortIndex: z.number(), 23 | text: z.string().min(1), 24 | }) 25 | 26 | export enum QuestionType { 27 | MULTIPLE_CHOICE = 'multiple_choice', 28 | OPEN = 'open', 29 | SCORE = 'score', 30 | SINGLE_CHOICE = 'single_choice', 31 | } 32 | 33 | const templateFormOpenQuestionSchema = baseTemplateFormQuestionSchema 34 | .merge(z.object({ questionType: z.literal(QuestionType.OPEN) })) 35 | 36 | const templateFormScoreQuestionSchema = baseTemplateFormQuestionSchema.merge(z.object({ 37 | labelMax: z.string().nullable(), 38 | labelMin: z.string().nullable(), 39 | maxScore: z.number().nullable(), 40 | questionType: z.literal(QuestionType.SCORE), 41 | })) 42 | 43 | const templateFormSingleChoiceQuestionSchema = baseTemplateFormQuestionSchema.merge(z.object({ 44 | choices: templateChoiceSchema.array().min(2), 45 | questionType: z.literal(QuestionType.SINGLE_CHOICE), 46 | })) 47 | 48 | const templateFormMultipleChoiceQuestionSchema = baseTemplateFormQuestionSchema.merge(z.object({ 49 | choices: z.array(templateChoiceSchema).min(2), 50 | maxChoices: z.number().nullable(), 51 | minChoices: z.number().nullable(), 52 | questionType: z.literal(QuestionType.MULTIPLE_CHOICE), 53 | })) 54 | 55 | export const templateFormQuestionSchema = z.discriminatedUnion('questionType', [ 56 | templateFormOpenQuestionSchema, 57 | templateFormScoreQuestionSchema, 58 | templateFormSingleChoiceQuestionSchema, 59 | templateFormMultipleChoiceQuestionSchema, 60 | ]) 61 | 62 | export const templateStepFormSchema = z.object({ 63 | title: z.string().min(1), 64 | description: z.string().nullable(), 65 | questions: z.array(templateFormQuestionSchema), 66 | }) 67 | 68 | export const templateUpdateFormSchema = z.object({ 69 | title: z.string().min(1), 70 | isOneTimeUse: z.boolean(), 71 | steps: templateStepFormSchema.array(), 72 | type: templateFormTypeEnum, 73 | }) 74 | 75 | export type TemplateUpdateForm = z.infer 76 | export type TemplateChoice = z.infer 77 | 78 | export type TemplateFormQuestion = z.infer 79 | export type TemplateFormOpenQuestion = z.infer 80 | export type TemplateFormScoreQuestion = z.infer 81 | export type TemplateFormSingleChoiceQuestion = z.infer 82 | export type TemplateFormMultipleChoiceQuestion = z.infer 83 | export type ChoiceUuid = z.infer 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Installation 4 | 5 | 6 | ```bash 7 | pnpm i formango 8 | ``` 9 | 10 | > The validation of this package relies on [Zod](https://zod.dev/), [Valibot](https://valibot.dev/) or [ArkType](https://arktype.io/), or any other schema library that implements the [standard schema spec](https://github.com/standard-schema/standard-schema). 11 | 12 | ## Why Go Bananas for Formango 13 | 14 | Formango takes the hassle out of form validation in your Vue applications, providing solid benefits that enhance your development process: 15 | 1. Type-Safe Confidence: Formango is built with TypeScript at its core, ensuring that your form validations are robust and free from type-related surprises. Catch errors at compile-time and enjoy a more confident development experience. 16 | 2. Built-in Standard Schema Spec Integration: Formango is built to integrate with Standard Schema Spec, which means it works with Zod, Valibot and ArkType. This means you can define your data structures with any of these libraries and effortlessly apply these schemas to your Vue forms using Formango. 17 | 3. Clean and Maintainable: Say goodbye to tangled validation logic. Formango promotes a clean and declarative approach to form validation, making your codebase easier to understand and maintain. 18 | 4. Flexibility in Your Hands: As a headless validation library, Formango adapts to your needs, whether it's handling complex and custom forms or a simple login form. Customize the validation to fit your specific use cases without compromising on quality. 19 | 5. Vue Ecosystem Friendly: Built-in devtools makes it easy to debug your complex forms. 20 | 6. Fruity: It follows the trend of fruit-based Vue libraries. 21 | 22 | 23 | ## Usage Example 24 | 25 | ```vue 26 | 62 | 63 | 64 | 65 | 66 | 67 | Submit 68 | 69 | 70 | ``` 71 | 72 | Refer to the [form](https://wisemen-digital.github.io/vue-formango/api/useForm), [field](https://wisemen-digital.github.io/vue-formango/api/field) and [array field](https://wisemen-digital.github.io/vue-formango/api/field-array) API for more details. -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Installation 4 | 5 | 6 | ```bash 7 | pnpm i formango 8 | ``` 9 | 10 | > The validation of this package relies on [Zod](https://zod.dev/), [Valibot](https://valibot.dev/) or [ArkType](https://arktype.io/), or any other schema library that implements the [standard schema spec](https://github.com/standard-schema/standard-schema). 11 | 12 | ## Why Go Bananas for Formango 13 | 14 | Formango takes the hassle out of form validation in your Vue applications, providing solid benefits that enhance your development process: 15 | 1. Type-Safe Confidence: Formango is built with TypeScript at its core, ensuring that your form validations are robust and free from type-related surprises. Catch errors at compile-time and enjoy a more confident development experience. 16 | 2. Built-in Standard Schema Spec Integration: Formango is built to integrate with Standard Schema Spec, which means it works with Zod, Valibot and ArkType. This means you can define your data structures with any of these libraries and effortlessly apply these schemas to your Vue forms using Formango. 17 | 3. Clean and Maintainable: Say goodbye to tangled validation logic. Formango promotes a clean and declarative approach to form validation, making your codebase easier to understand and maintain. 18 | 4. Flexibility in Your Hands: As a headless validation library, Formango adapts to your needs, whether it's handling complex and custom forms or a simple login form. Customize the validation to fit your specific use cases without compromising on quality. 19 | 5. Vue Ecosystem Friendly: Built-in devtools makes it easy to debug your complex forms. 20 | 6. Fruity: It follows the trend of fruit-based Vue libraries. 21 | 22 | 23 | ## Usage Example 24 | 25 | ```vue 26 | 62 | 63 | 64 | 65 | 66 | 67 | Submit 68 | 69 | 70 | ``` 71 | 72 | Refer to the [form](https://wisemen-digital.github.io/vue-formango/api/useForm), [field](https://wisemen-digital.github.io/vue-formango/api/field) and [array field](https://wisemen-digital.github.io/vue-formango/api/field-array) API for more details. -------------------------------------------------------------------------------- /src/types/standardSpec.type.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/no-namespace */ 2 | /** 3 | * The Standard Schema interface. 4 | */ 5 | export interface StandardSchemaV1 { 6 | /** 7 | * The Standard Schema properties. 8 | */ 9 | readonly '~standard': StandardSchemaV1.Props 10 | } 11 | 12 | export declare namespace StandardSchemaV1 { 13 | /** 14 | * The Standard Schema properties interface. 15 | */ 16 | export interface Props { 17 | /** 18 | * Inferred types associated with the schema. 19 | */ 20 | readonly types?: Types | undefined 21 | /** 22 | * Validates unknown input values. 23 | */ 24 | readonly validate: ( 25 | value: unknown, 26 | ) => Promise> | Result 27 | /** 28 | * The vendor name of the schema library. 29 | */ 30 | readonly vendor: string 31 | /** 32 | * The version number of the standard. 33 | */ 34 | readonly version: 1 35 | } 36 | 37 | /** 38 | * The result interface of the validate function. 39 | */ 40 | export type Result = FailureResult | SuccessResult 41 | 42 | /** 43 | * The result interface if validation succeeds. 44 | */ 45 | export interface SuccessResult { 46 | /** 47 | * The non-existent issues. 48 | */ 49 | readonly issues?: undefined 50 | /** 51 | * The typed output value. 52 | */ 53 | readonly value: Output 54 | } 55 | 56 | /** 57 | * The result interface if validation fails. 58 | */ 59 | export interface FailureResult { 60 | /** 61 | * The issues of failed validation. 62 | */ 63 | readonly issues: ReadonlyArray 64 | } 65 | 66 | /** 67 | * The issue interface of the failure output. 68 | */ 69 | export interface Issue { 70 | /** 71 | * The error message of the issue. 72 | */ 73 | readonly message: string 74 | /** 75 | * The path of the issue, if any. 76 | */ 77 | readonly path?: ReadonlyArray | undefined 78 | } 79 | 80 | /** 81 | * The path segment interface of the issue. 82 | */ 83 | export interface PathSegment { 84 | /** 85 | * The key representing a path segment. 86 | */ 87 | readonly key: PropertyKey 88 | } 89 | 90 | /** 91 | * The Standard Schema types interface. 92 | */ 93 | export interface Types { 94 | /** 95 | * The input type of the schema. 96 | */ 97 | readonly input: Input 98 | /** 99 | * The output type of the schema. 100 | */ 101 | readonly output: Output 102 | } 103 | 104 | /** 105 | * Infers the input type of a Standard Schema. 106 | */ 107 | export type InferInput = NonNullable< 108 | Schema['~standard']['types'] 109 | >['input'] 110 | 111 | /** 112 | * Infers the output type of a Standard Schema. 113 | */ 114 | export type InferOutput = NonNullable< 115 | Schema['~standard']['types'] 116 | >['output'] 117 | 118 | // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace 119 | export {} 120 | } 121 | -------------------------------------------------------------------------------- /test/useFormFieldRegister.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | import { z } from 'zod' 7 | 8 | import { useForm } from '../src/lib/useForm' 9 | import { 10 | basicArraySchema, 11 | objectArraySchema, 12 | objectSchema, 13 | } from './testUtils' 14 | 15 | describe('register a field from a field or fieldArray', () => { 16 | it('should register a field from a field', () => { 17 | const form = useForm({ 18 | schema: objectSchema, 19 | onSubmit: (data) => { 20 | return data 21 | }, 22 | }) 23 | 24 | const a = form.register('a') 25 | const b = a.register('b') 26 | 27 | expect(b.modelValue.value).toBeNull() 28 | 29 | expect(form.state.value).toEqual({ a: { b: null } }) 30 | }) 31 | 32 | it('should register a field from a field with a default value', () => { 33 | const form = useForm({ 34 | schema: objectSchema, 35 | onSubmit: (data) => { 36 | return data 37 | }, 38 | }) 39 | 40 | const a = form.register('a') 41 | const b = a.register('b', 'John') 42 | 43 | expect(b.modelValue.value).toBe('John') 44 | 45 | expect(form.state.value).toEqual({ a: { b: 'John' } }) 46 | }) 47 | 48 | it('should register a fieldArray with a default value from a field', () => { 49 | const form = useForm({ 50 | schema: z.object({ obj: z.object({ array: z.array(z.string()) }) }), 51 | onSubmit: (data) => { 52 | return data 53 | }, 54 | }) 55 | 56 | const obj = form.register('obj') 57 | const array = obj.registerArray('array', [ 58 | 'John', 59 | ]) 60 | 61 | expect(array.modelValue.value).toEqual([ 62 | 'John', 63 | ]) 64 | }) 65 | 66 | it('should register a field from a field which has been registered from a field', () => { 67 | const form = useForm({ 68 | schema: objectSchema, 69 | onSubmit: (data) => { 70 | return data 71 | }, 72 | }) 73 | 74 | const a = form.register('a') 75 | const b = a.register('bObj') 76 | const c = b.register('c') 77 | 78 | expect(c.modelValue.value).toBeNull() 79 | 80 | expect(form.state.value).toEqual({ a: { bObj: { c: null } } }) 81 | }) 82 | 83 | it('should register a field from an array field', () => { 84 | const form = useForm({ 85 | schema: basicArraySchema, 86 | onSubmit: (data) => { 87 | return data 88 | }, 89 | }) 90 | 91 | const array = form.registerArray('array') 92 | 93 | const array0Name = array.register('0') 94 | 95 | expect(array0Name.modelValue.value).toBeNull() 96 | 97 | expect(form.state.value).toEqual({ 98 | array: [ 99 | null, 100 | ], 101 | }) 102 | }) 103 | 104 | // Bug report: 105 | // When an object array is registered with a default value, and its children are not registered 106 | // It's not possible to remove an array index 107 | 108 | it('should be possible to modify a field array without registering its children', () => { 109 | const form = useForm({ 110 | initialState: { 111 | array: [ 112 | { name: 'John' }, 113 | { name: 'Doe' }, 114 | ], 115 | }, 116 | schema: objectArraySchema, 117 | onSubmit: (data) => { 118 | return data 119 | }, 120 | }) 121 | 122 | const array = form.registerArray('array') 123 | 124 | array.register('0') 125 | 126 | array.remove(1) 127 | 128 | expect(form.state.value).toEqual({ 129 | array: [ 130 | { name: 'John' }, 131 | ], 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /docs/best-practices/custom-input.md: -------------------------------------------------------------------------------- 1 | # Custom input 2 | 3 | Instead of binding all the events in the native input, it is best to create a wrapper around an input which handles all the bindings. 4 | Here is a custom input which shows the error if the user has focused and blurred the input. 5 | 6 | In this example your v-model, blur and errors automatically get bound by using v-bind on a Field. 7 | This is a very basic example as a starting point. 8 | 9 | 10 | ::: code-group 11 | ```vue [CustomInput.vue] 12 | 43 | 44 | 45 | 46 | 50 | 51 | {{ errors._errors[0] }} 52 | 53 | 54 | 55 | ``` 56 | 57 | ```vue [FormView.vue] 58 | 110 | 111 | 112 | 113 | 114 | 115 | Submit 116 | 117 | 118 | ``` 119 | ::: -------------------------------------------------------------------------------- /docs/best-practices/i18n.md: -------------------------------------------------------------------------------- 1 | # I18n 2 | 3 | To translate your error messages, you can use the vue-i18n package and zod to set global, translated messaged. 4 | 5 | ::: code-group 6 | 7 | ```ts [zod.config.ts] 8 | import { z } from 'zod' 9 | import i18n from '@/plugins/i18n' 10 | 11 | const customErrorMap: z.ZodErrorMap = (issue, ctx) => { 12 | const t = i18n.global.t 13 | if (issue.code === z.ZodIssueCode.invalid_type) 14 | return { message: t('errors.invalid_type') } 15 | 16 | if (issue.code === z.ZodIssueCode.invalid_union) 17 | return { message: t('errors.invalid_union') } 18 | if (issue.code === z.ZodIssueCode.invalid_string) { 19 | if (issue.validation === 'email') 20 | return { message: t('errors.invalid_email') } 21 | if (issue.validation === 'url') 22 | return { message: t('errors.invalid_url') } 23 | if (issue.validation === 'uuid') 24 | return { message: t('errors.invalid_uuid') } 25 | if (issue.validation === 'regex') 26 | return { message: t('errors.invalid_regex') } 27 | if (issue.validation === 'datetime') 28 | return { message: t('errors.invalid_datetime') } 29 | 30 | return { message: t('errors.invalid_string') } 31 | } 32 | 33 | if (issue.code === z.ZodIssueCode.invalid_date) 34 | return { message: t('errors.invalid_date') } 35 | if (issue.code === z.ZodIssueCode.too_big) { 36 | if (issue.type === 'string') 37 | return { message: t('errors.too_big_string', { count: issue.maximum }) } 38 | if (issue.type === 'number') 39 | return { message: t('errors.too_big_number', { count: issue.maximum }) } 40 | if (issue.type === 'array') 41 | return { message: t('errors.too_big_array', { count: issue.maximum }) } 42 | if (issue.type === 'date') 43 | return { message: t('errors.too_big_date', { count: issue.maximum }) } 44 | return { message: t('errors.too_big', { count: issue.maximum }) } 45 | } 46 | if (issue.code === z.ZodIssueCode.too_small) { 47 | if (issue.type === 'string') 48 | return { message: t('errors.too_small_string', { count: issue.minimum }) } 49 | if (issue.type === 'number') 50 | return { message: t('errors.too_small_number', { count: issue.minimum }) } 51 | if (issue.type === 'array') 52 | return { message: t('errors.too_small_array', { count: issue.minimum }) } 53 | if (issue.type === 'date') 54 | return { message: t('errors.too_small_date', { v: issue.minimum }) } 55 | return { message: t('errors.too_small', { count: issue.minimum }) } 56 | } 57 | 58 | return { message: ctx.defaultError } 59 | } 60 | 61 | z.setErrorMap(customErrorMap) 62 | 63 | const setupZod = (): void => { 64 | z.setErrorMap(customErrorMap) 65 | } 66 | 67 | export default setupZod 68 | ``` 69 | 70 | ```json [en.json] 71 | { 72 | "errors": { 73 | "invalid_type": "This field is required.", 74 | "invalid_email": "Invalid email.", 75 | "invalid_url": "Invalid URL.", 76 | "invalid_uuid": "Invalid UUID.", 77 | "invalid_regex": "Invalid REGEX.", 78 | "invalid_datetime": "Invalid datetime.", 79 | "invalid_string": "Invalid text.", 80 | "invalid_date": "Invalid date.", 81 | "too_big_string": "Max. {count} characters long.", 82 | "too_big_number": "Max. {count}.", 83 | "too_big_array": "Max. {count} elements.", 84 | "too_big_date": "Max. {count} date.", 85 | "too_big": "Max. {count} big.", 86 | "too_small_string": "This field is required. | Min. {count} characters long.", 87 | "too_small_number": "Min. {count} big.", 88 | "too_small_array": "Min. {count} elements.", 89 | "too_small_date": "Min. {count} date.", 90 | "too_small": "Min. {count} long." 91 | } 92 | } 93 | ``` 94 | 95 | ```ts [main.ts] 96 | import './configs/zod.config' 97 | ``` 98 | ::: -------------------------------------------------------------------------------- /test/useFormSubmit.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | sleep, 11 | } from './testUtils' 12 | 13 | describe('submit', () => { 14 | it('should submit', async () => { 15 | let submitted = false 16 | const form = useForm({ 17 | schema: basicSchema, 18 | onSubmit: (data) => { 19 | submitted = true 20 | 21 | return data 22 | }, 23 | }) 24 | 25 | form.register('name', 'John') 26 | 27 | await sleep(0) 28 | await form.submit() 29 | 30 | expect(submitted).toBeTruthy() 31 | expect(form.hasAttemptedToSubmit.value).toBeTruthy() 32 | }) 33 | 34 | it('should submit with data', async () => { 35 | let submitted = false 36 | let submittedData = null 37 | const form = useForm({ 38 | schema: basicSchema, 39 | onSubmit: (data) => { 40 | submitted = true 41 | submittedData = data 42 | 43 | return data 44 | }, 45 | }) 46 | 47 | form.register('name', 'John') 48 | 49 | await sleep(0) 50 | 51 | form.submit() 52 | 53 | await sleep(0) 54 | 55 | expect(submitted).toBeTruthy() 56 | expect(submittedData).toEqual({ name: 'John' }) 57 | }) 58 | 59 | it('should blur all fields', async () => { 60 | const form = useForm({ 61 | schema: basicSchema, 62 | onSubmit: (data) => { 63 | return data 64 | }, 65 | 66 | }) 67 | 68 | const name = form.register('name', 'John') 69 | 70 | expect(name.isTouched.value).toBeFalsy() 71 | 72 | await sleep(0) 73 | await form.submit() 74 | 75 | expect(name.isTouched.value).toBeTruthy() 76 | }) 77 | 78 | it('should not submit if there are errors', async () => { 79 | let submitted = false 80 | const form = useForm({ 81 | schema: basicSchema, 82 | onSubmit: (data) => { 83 | submitted = true 84 | 85 | return data 86 | }, 87 | 88 | }) 89 | 90 | form.register('name', 'Jon') 91 | 92 | await sleep(0) 93 | await form.submit() 94 | 95 | expect(submitted).toBeFalsy() 96 | }) 97 | 98 | it('should call `onSubmitError` if there are errors', async () => { 99 | let isCalled = false 100 | 101 | const form = useForm({ 102 | schema: basicSchema, 103 | onSubmit: (data) => { 104 | return data 105 | }, 106 | onSubmitError: () => { 107 | isCalled = true 108 | }, 109 | }) 110 | 111 | form.register('name', 'Jon') 112 | 113 | await sleep(0) 114 | await form.submit() 115 | 116 | expect(isCalled).toBeTruthy() 117 | }) 118 | 119 | it('should call `onSubmitError` if there are errors and pass data and errors to the callback function', async () => { 120 | let isCalled = false 121 | let submittedErrorData = null 122 | let submittedErrors = null 123 | 124 | const form = useForm({ 125 | schema: basicSchema, 126 | onSubmit: (data) => { 127 | return data 128 | }, 129 | onSubmitError: ({ 130 | data, errors, 131 | }) => { 132 | isCalled = true 133 | submittedErrorData = data 134 | submittedErrors = errors 135 | }, 136 | }) 137 | 138 | form.register('name', 'Jon') 139 | 140 | await sleep(0) 141 | await form.submit() 142 | 143 | expect(submittedErrorData).toEqual({ name: 'Jon' }) 144 | 145 | expect(submittedErrors).toEqual([ 146 | { 147 | message: 'String must contain at least 4 character(s)', 148 | path: 'name', 149 | }, 150 | ]) 151 | 152 | expect(isCalled).toBeTruthy() 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /test/useFormInitialStateNull.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | objectSchema, 11 | } from './testUtils' 12 | 13 | describe('initial state type allows nulls', () => { 14 | it('initial state primitive is correct', () => { 15 | const form = useForm({ 16 | initialState: { name: 'Joe' }, 17 | schema: basicSchema, 18 | onSubmit: (data) => { 19 | return data 20 | }, 21 | }) 22 | 23 | expect(form.state.value).toEqual({ name: 'Joe' }) 24 | }) 25 | 26 | it('initial state primitive is nullable', () => { 27 | const form = useForm({ 28 | initialState: { name: null }, 29 | schema: basicSchema, 30 | onSubmit: (data) => { 31 | return data 32 | }, 33 | }) 34 | 35 | expect(form.state.value).toEqual({ name: null }) 36 | }) 37 | 38 | it('initial state primitive errors with wrong value', () => { 39 | const form = useForm({ 40 | initialState: { 41 | // @ts-expect-error - Throws error because a expect a string and a number is given 42 | name: 123, 43 | }, 44 | schema: basicSchema, 45 | onSubmit: (data) => { 46 | return data 47 | }, 48 | }) 49 | 50 | expect(form.state.value).toEqual({ name: 123 }) 51 | }) 52 | 53 | it('initial state object is nullable', () => { 54 | const form = useForm({ 55 | initialState: { a: null }, 56 | schema: objectSchema, 57 | onSubmit: (data) => { 58 | return data 59 | }, 60 | }) 61 | 62 | expect(form.state.value).toEqual({ a: null }) 63 | }) 64 | 65 | it('initial state object errors with wrong value', () => { 66 | const form = useForm({ 67 | initialState: { 68 | // @ts-expect-error - Throws error because a is an object and a string is given 69 | a: '123', 70 | }, 71 | schema: objectSchema, 72 | onSubmit: (data) => { 73 | return data 74 | }, 75 | }) 76 | 77 | expect(form.state.value).toEqual({ a: '123' }) 78 | }) 79 | 80 | it('initial state object nested values are nullable', () => { 81 | const form = useForm({ 82 | initialState: { 83 | a: { 84 | b: null, 85 | bObj: null, 86 | }, 87 | }, 88 | schema: objectSchema, 89 | onSubmit: (data) => { 90 | return data 91 | }, 92 | }) 93 | 94 | expect(form.state.value).toEqual({ 95 | a: { 96 | b: null, 97 | bObj: null, 98 | }, 99 | }) 100 | }) 101 | 102 | it('initial state object nested values errors with wrong value', () => { 103 | const form = useForm({ 104 | initialState: { 105 | a: { 106 | b: '123', 107 | // @ts-expect-error - Throws error bObject an object and a string is given 108 | bObj: '123', 109 | }, 110 | }, 111 | schema: objectSchema, 112 | onSubmit: (data) => { 113 | return data 114 | }, 115 | }) 116 | 117 | expect(form.state.value).toEqual({ 118 | a: { 119 | b: '123', 120 | bObj: '123', 121 | }, 122 | }) 123 | }) 124 | 125 | it('initial state object is correct', () => { 126 | const form = useForm({ 127 | initialState: { 128 | a: { 129 | b: '123', 130 | bObj: { c: '123' }, 131 | }, 132 | }, 133 | schema: objectSchema, 134 | onSubmit: (data) => { 135 | return data 136 | }, 137 | }) 138 | 139 | expect(form.state.value).toEqual({ 140 | a: { 141 | b: '123', 142 | bObj: { c: '123' }, 143 | }, 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | const bestPractices = [ 4 | { 5 | link: '/best-practices/i18n', 6 | text: 'I18n', 7 | }, 8 | { 9 | link: '/best-practices/custom-input', 10 | text: 'Custom input', 11 | }, 12 | ] 13 | 14 | const guide = [ 15 | { 16 | link: '/guide/getting-started', 17 | text: 'Getting started', 18 | }, 19 | { 20 | link: '/guide/devtools', 21 | text: 'Devtools', 22 | }, 23 | ] 24 | 25 | const api = [ 26 | { 27 | link: '/api/useForm', 28 | text: 'useForm', 29 | }, 30 | { 31 | link: '/api/field', 32 | text: 'Field', 33 | }, 34 | { 35 | link: '/api/field-array', 36 | text: 'Field array', 37 | }, 38 | ] 39 | 40 | const examples = [ 41 | { 42 | link: '/examples/subforms', 43 | text: 'Subforms', 44 | }, 45 | { 46 | link: '/examples/external-errors', 47 | text: 'External errors', 48 | }, 49 | ] 50 | 51 | // https://vitepress.dev/reference/site-config 52 | export default defineConfig({ 53 | title: 'Formango', 54 | base: '/vue-formango/', 55 | description: 'A lightweight, zod-based Vue form library', 56 | head: [ 57 | [ 58 | 'link', 59 | { 60 | href: '/forms/apple-touch-icon.png', 61 | rel: 'apple-touch-icon', 62 | sizes: '180x180', 63 | }, 64 | ], 65 | [ 66 | 'link', 67 | { 68 | href: '/forms/favicon-32x32.png', 69 | rel: 'icon', 70 | sizes: '32x32', 71 | type: 'image/png', 72 | }, 73 | ], 74 | [ 75 | 'link', 76 | { 77 | href: '/forms/favicon-16x16.png', 78 | rel: 'icon', 79 | sizes: '16x16', 80 | type: 'image/png', 81 | }, 82 | ], 83 | [ 84 | 'link', 85 | { 86 | href: '/forms/site.webmanifest', 87 | rel: 'manifest', 88 | }, 89 | ], 90 | [ 91 | 'link', 92 | { 93 | color: '#da532c', 94 | href: '/forms/safari-pinned-tab.svg', 95 | rel: 'mask-icon', 96 | }, 97 | ], 98 | [ 99 | 'link', 100 | { 101 | href: '/forms/favicon.ico', 102 | rel: 'shortcut icon', 103 | }, 104 | ], 105 | [ 106 | 'meta', 107 | { 108 | name: 'msapplication-TileColor', 109 | content: '#da532c', 110 | }, 111 | ], 112 | [ 113 | 'meta', 114 | { 115 | name: 'msapplication-config', 116 | content: '/forms/browserconfig.xml', 117 | }, 118 | ], 119 | [ 120 | 'meta', 121 | { 122 | name: 'theme-color', 123 | content: '#ffffff', 124 | }, 125 | ], 126 | ], 127 | themeConfig: { 128 | // https://vitepress.dev/reference/default-theme-config 129 | nav: [ 130 | { 131 | link: '/guide/getting-started', 132 | text: 'Guide', 133 | }, 134 | { 135 | link: '/api/useForm', 136 | text: 'API docs', 137 | }, 138 | 139 | ], 140 | search: { provider: 'local' }, 141 | 142 | sidebar: [ 143 | { 144 | items: guide, 145 | text: 'Guide', 146 | }, 147 | { 148 | items: bestPractices, 149 | text: 'Best practices', 150 | }, 151 | { 152 | items: api, 153 | text: 'API', 154 | }, 155 | { 156 | items: examples, 157 | text: 'Examples', 158 | }, 159 | { 160 | link: '/CHANGELOG', 161 | text: 'Changelog', 162 | }, 163 | { 164 | link: '/playground', 165 | text: 'Playground', 166 | }, 167 | 168 | ], 169 | 170 | socialLinks: [ 171 | { 172 | icon: 'github', 173 | link: 'https://github.com/wouterlms/forms', 174 | }, 175 | ], 176 | }, 177 | }) 178 | -------------------------------------------------------------------------------- /src/types/utils.type.ts: -------------------------------------------------------------------------------- 1 | declare const $NestedValue: unique symbol 2 | 3 | type NestedValue = { 4 | [$NestedValue]: never 5 | } & TValue 6 | /* 7 | Projects that React Hook Form installed don't include the DOM library need these interfaces to compile. 8 | React Native applications is no DOM available. The JavaScript runtime is ES6/ES2015 only. 9 | These definitions allow such projects to compile with only --lib ES6. 10 | Warning: all of these interfaces are empty. 11 | If you want type definitions for various properties, you need to add `--lib DOM` (via command line or tsconfig.json). 12 | */ 13 | 14 | export type Noop = () => void 15 | 16 | interface File extends Blob { 17 | readonly name: string 18 | readonly lastModified: number 19 | } 20 | 21 | interface FileList { 22 | [index: number]: File 23 | item: (index: number) => File | null 24 | readonly length: number 25 | } 26 | 27 | export type Primitive = 28 | | bigint 29 | | boolean 30 | | number 31 | | string 32 | | symbol 33 | | null 34 | | undefined 35 | 36 | export type BrowserNativeObject = Date | File | FileList 37 | 38 | export type EmptyObject = { [K in number | string]: never } 39 | 40 | export type NonUndefined = T extends undefined ? never : T 41 | 42 | export type LiteralUnion = 43 | | (U & { _?: never }) 44 | | T 45 | 46 | export type DeepPartial = T extends BrowserNativeObject | NestedValue 47 | ? T 48 | : { [K in keyof T]?: DeepPartial } 49 | 50 | export type DeepPartialSkipArrayKey = T extends 51 | | BrowserNativeObject 52 | | NestedValue 53 | ? T 54 | : T extends readonly any[] 55 | ? { [K in keyof T]: DeepPartialSkipArrayKey } 56 | : { [K in keyof T]?: DeepPartialSkipArrayKey } 57 | 58 | /** 59 | * Checks whether the type is any 60 | * See {@link https://stackoverflow.com/a/49928360/3406963} 61 | * @typeParam T - type which may be any 62 | * ``` 63 | * IsAny = true 64 | * IsAny = false 65 | * ``` 66 | */ 67 | export type IsAny = 0 extends 1 & T ? true : false 68 | 69 | /** 70 | * Checks whether the type is never 71 | * @typeParam T - type which may be never 72 | * ``` 73 | * IsAny = true 74 | * IsAny = false 75 | * ``` 76 | */ 77 | export type IsNever = [T] extends [never] ? true : false 78 | 79 | /** 80 | * Checks whether T1 can be exactly (mutually) assigned to T2 81 | * @typeParam T1 - type to check 82 | * @typeParam T2 - type to check against 83 | * ``` 84 | * IsEqual = true 85 | * IsEqual<'foo', 'foo'> = true 86 | * IsEqual = false 87 | * IsEqual = false 88 | * IsEqual = false 89 | * IsEqual<'foo', string> = false 90 | * IsEqual<'foo' | 'bar', 'foo'> = boolean // 'foo' is assignable, but 'bar' is not (true | false) -> boolean 91 | * ``` 92 | */ 93 | export type IsEqual = T1 extends T2 94 | ? (() => G extends T1 ? 1 : 2) extends () => G extends T2 ? 1 : 2 95 | ? true 96 | : false 97 | : false 98 | 99 | export type DeepMap = IsAny extends true 100 | ? any 101 | : T extends BrowserNativeObject | NestedValue 102 | ? TValue 103 | : T extends object 104 | ? { [K in keyof T]: DeepMap, TValue> } 105 | : TValue 106 | 107 | export type IsFlatObject = Extract< 108 | Exclude, 109 | object | any[] 110 | > extends never 111 | ? true 112 | : false 113 | 114 | export type Merge = { 115 | [K in keyof A | keyof B]?: K extends keyof A & keyof B 116 | ? [A[K], B[K]] extends [object, object] 117 | ? Merge 118 | : A[K] | B[K] 119 | : K extends keyof A 120 | ? A[K] 121 | : K extends keyof B 122 | ? B[K] 123 | : never; 124 | } 125 | 126 | export type NullableKeys = { 127 | [K in keyof T]: T[K] | null 128 | } 129 | 130 | export type NestedNullableKeys = { 131 | [K in keyof T]: T[K] extends object ? NestedNullableKeys | null : T[K] | null 132 | } 133 | -------------------------------------------------------------------------------- /test/useFormIsValid.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | objectSchema, 11 | sleep, 12 | twoDimensionalArraySchema, 13 | } from './testUtils' 14 | 15 | describe('isValid', () => { 16 | it('should be false by default when form is not valid', async () => { 17 | const form = useForm({ 18 | schema: basicSchema, 19 | onSubmit: (data) => { 20 | return data 21 | }, 22 | }) 23 | 24 | await sleep(0) 25 | 26 | expect(form.isValid.value).toBeFalsy() 27 | }) 28 | 29 | it('should be true if all fields are valid', async () => { 30 | const form = useForm({ 31 | schema: basicSchema, 32 | onSubmit: (data) => { 33 | return data 34 | }, 35 | }) 36 | 37 | await sleep(0) 38 | 39 | expect(form.isValid.value).toBeFalsy() 40 | 41 | form.register('name', 'John') 42 | 43 | await sleep(0) 44 | 45 | expect(form.isValid.value).toBeTruthy() 46 | }) 47 | 48 | it('an field with object isValid value should be based on its children being valid', async () => { 49 | const form = useForm({ 50 | schema: objectSchema, 51 | onSubmit: (data) => { 52 | return data 53 | }, 54 | }) 55 | const a = form.register('a') 56 | const ab = form.register('a.b') 57 | const abObj = form.register('a.bObj') 58 | const abObjC = form.register('a.bObj.c') 59 | 60 | await sleep(0) 61 | 62 | expect(form.isValid.value).toBeFalsy() 63 | expect(a.isValid.value).toBeFalsy() 64 | expect(ab.isValid.value).toBeFalsy() 65 | expect(abObj.isValid.value).toBeFalsy() 66 | expect(abObjC.isValid.value).toBeFalsy() 67 | 68 | abObjC.setValue('John') 69 | abObj.setValue({ c: 'John' }) 70 | 71 | await sleep(0) 72 | 73 | expect(form.isValid.value).toBeFalsy() 74 | expect(a.isValid.value).toBeFalsy() 75 | expect(ab.isValid.value).toBeFalsy() 76 | expect(abObj.isValid.value).toBeTruthy() 77 | expect(abObjC.isValid.value).toBeTruthy() 78 | 79 | ab.setValue('John') 80 | 81 | await sleep(0) 82 | 83 | expect(form.isValid.value).toBeTruthy() 84 | expect(a.isValid.value).toBeTruthy() 85 | expect(ab.isValid.value).toBeTruthy() 86 | expect(abObj.isValid.value).toBeTruthy() 87 | expect(abObjC.isValid.value).toBeTruthy() 88 | }) 89 | 90 | it('should be false if an element of a fieldArray is invalid', async () => { 91 | const form = useForm({ 92 | schema: twoDimensionalArraySchema, 93 | onSubmit: (data) => { 94 | return data 95 | }, 96 | }) 97 | 98 | const array = form.registerArray('array') 99 | const array0 = array.register('0') 100 | const array1 = array.register('1') 101 | const array00 = array0.register('0') 102 | const array00Name = array00.register('name') 103 | const array01 = array0.register('1') 104 | const array01Name = array01.register('name') 105 | 106 | await sleep(0) 107 | 108 | expect(form.isValid.value).toBeFalsy() 109 | expect(array.isValid.value).toBeFalsy() 110 | expect(array0.isValid.value).toBeFalsy() 111 | expect(array00.isValid.value).toBeFalsy() 112 | expect(array00Name.isValid.value).toBeFalsy() 113 | expect(array01.isValid.value).toBeFalsy() 114 | expect(array01Name.isValid.value).toBeFalsy() 115 | expect(array1.isValid.value).toBeFalsy() 116 | 117 | array00Name.setValue('John') 118 | 119 | await sleep(0) 120 | 121 | expect(form.isValid.value).toBeFalsy() 122 | expect(array.isValid.value).toBeFalsy() 123 | expect(array0.isValid.value).toBeFalsy() 124 | expect(array01.isValid.value).toBeFalsy() 125 | expect(array01Name.isValid.value).toBeFalsy() 126 | expect(array1.isValid.value).toBeFalsy() 127 | 128 | expect(array00.isValid.value).toBeTruthy() 129 | expect(array00Name.isValid.value).toBeTruthy() 130 | 131 | array01.setValue({ name: 'John' }) 132 | 133 | await sleep(0) 134 | 135 | expect(form.isValid.value).toBeFalsy() 136 | expect(array.isValid.value).toBeFalsy() 137 | expect(array1.isValid.value).toBeFalsy() 138 | 139 | expect(array0.isValid.value).toBeTruthy() 140 | expect(array01.isValid.value).toBeTruthy() 141 | expect(array01Name.isValid.value).toBeTruthy() 142 | expect(array00.isValid.value).toBeTruthy() 143 | 144 | array1.setValue([ 145 | { name: 'John' }, 146 | ]) 147 | 148 | await sleep(0) 149 | 150 | expect(form.isValid.value).toBeTruthy() 151 | expect(array.isValid.value).toBeTruthy() 152 | expect(array0.isValid.value).toBeTruthy() 153 | expect(array1.isValid.value).toBeTruthy() 154 | expect(array00.isValid.value).toBeTruthy() 155 | expect(array01.isValid.value).toBeTruthy() 156 | expect(array01Name.isValid.value).toBeTruthy() 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js: -------------------------------------------------------------------------------- 1 | // node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/env.js 2 | function getDevtoolsGlobalHook() { 3 | return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__; 4 | } 5 | function getTarget() { 6 | return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}; 7 | } 8 | var isProxyAvailable = typeof Proxy === "function"; 9 | 10 | // node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/const.js 11 | var HOOK_SETUP = "devtools-plugin:setup"; 12 | var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set"; 13 | 14 | // node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/time.js 15 | var supported; 16 | var perf; 17 | function isPerformanceSupported() { 18 | var _a; 19 | if (supported !== void 0) { 20 | return supported; 21 | } 22 | if (typeof window !== "undefined" && window.performance) { 23 | supported = true; 24 | perf = window.performance; 25 | } else if (typeof global !== "undefined" && ((_a = global.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) { 26 | supported = true; 27 | perf = global.perf_hooks.performance; 28 | } else { 29 | supported = false; 30 | } 31 | return supported; 32 | } 33 | function now() { 34 | return isPerformanceSupported() ? perf.now() : Date.now(); 35 | } 36 | 37 | // node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/proxy.js 38 | var ApiProxy = class { 39 | constructor(plugin, hook) { 40 | this.target = null; 41 | this.targetQueue = []; 42 | this.onQueue = []; 43 | this.plugin = plugin; 44 | this.hook = hook; 45 | const defaultSettings = {}; 46 | if (plugin.settings) { 47 | for (const id in plugin.settings) { 48 | const item = plugin.settings[id]; 49 | defaultSettings[id] = item.defaultValue; 50 | } 51 | } 52 | const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`; 53 | let currentSettings = Object.assign({}, defaultSettings); 54 | try { 55 | const raw = localStorage.getItem(localSettingsSaveId); 56 | const data = JSON.parse(raw); 57 | Object.assign(currentSettings, data); 58 | } catch (e) { 59 | } 60 | this.fallbacks = { 61 | getSettings() { 62 | return currentSettings; 63 | }, 64 | setSettings(value) { 65 | try { 66 | localStorage.setItem(localSettingsSaveId, JSON.stringify(value)); 67 | } catch (e) { 68 | } 69 | currentSettings = value; 70 | }, 71 | now() { 72 | return now(); 73 | } 74 | }; 75 | if (hook) { 76 | hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => { 77 | if (pluginId === this.plugin.id) { 78 | this.fallbacks.setSettings(value); 79 | } 80 | }); 81 | } 82 | this.proxiedOn = new Proxy({}, { 83 | get: (_target, prop) => { 84 | if (this.target) { 85 | return this.target.on[prop]; 86 | } else { 87 | return (...args) => { 88 | this.onQueue.push({ 89 | method: prop, 90 | args 91 | }); 92 | }; 93 | } 94 | } 95 | }); 96 | this.proxiedTarget = new Proxy({}, { 97 | get: (_target, prop) => { 98 | if (this.target) { 99 | return this.target[prop]; 100 | } else if (prop === "on") { 101 | return this.proxiedOn; 102 | } else if (Object.keys(this.fallbacks).includes(prop)) { 103 | return (...args) => { 104 | this.targetQueue.push({ 105 | method: prop, 106 | args, 107 | resolve: () => { 108 | } 109 | }); 110 | return this.fallbacks[prop](...args); 111 | }; 112 | } else { 113 | return (...args) => { 114 | return new Promise((resolve) => { 115 | this.targetQueue.push({ 116 | method: prop, 117 | args, 118 | resolve 119 | }); 120 | }); 121 | }; 122 | } 123 | } 124 | }); 125 | } 126 | async setRealTarget(target) { 127 | this.target = target; 128 | for (const item of this.onQueue) { 129 | this.target.on[item.method](...item.args); 130 | } 131 | for (const item of this.targetQueue) { 132 | item.resolve(await this.target[item.method](...item.args)); 133 | } 134 | } 135 | }; 136 | 137 | // node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/index.js 138 | function setupDevtoolsPlugin(pluginDescriptor, setupFn) { 139 | const descriptor = pluginDescriptor; 140 | const target = getTarget(); 141 | const hook = getDevtoolsGlobalHook(); 142 | const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy; 143 | if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) { 144 | hook.emit(HOOK_SETUP, pluginDescriptor, setupFn); 145 | } else { 146 | const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null; 147 | const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || []; 148 | list.push({ 149 | pluginDescriptor: descriptor, 150 | setupFn, 151 | proxy 152 | }); 153 | if (proxy) 154 | setupFn(proxy.proxiedTarget); 155 | } 156 | } 157 | export { 158 | isPerformanceSupported, 159 | now, 160 | setupDevtoolsPlugin 161 | }; 162 | //# sourceMappingURL=vitepress___@vue_devtools-api.js.map 163 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/api/field.md: -------------------------------------------------------------------------------- 1 | # Field 2 | 3 | Field that gets returned from the register field function from the form object. You can build a mapper, that maps the field to a custom input field, so you can just v-bind the entire object. 4 | 5 | ## Usage Default 6 | 7 | ```ts 8 | // Registers and returns a field 9 | import type { Field } from 'formango' 10 | import { formatErrorsToZodFormattedError } from 'formango' 11 | import type { 12 | ZodFormattedError, 13 | } from 'zod' 14 | 15 | const name = form.register('name', 'default name') 16 | const email = form.register('email') 17 | 18 | export function toFormField(field: Field): { 19 | 'isTouched': boolean | undefined 20 | 'errors': ZodFormattedError 21 | 'modelValue': TDefaultValue extends undefined ? TValue | null : TValue 22 | 'onBlur': () => void 23 | 'onUpdate:modelValue': (value: TValue | null) => void 24 | } { 25 | return { 26 | 'isTouched': field.isTouched.value, 27 | 'errors': formatErrorsToZodFormattedError(field.errors.value), 28 | 'modelValue': field.modelValue.value, 29 | 'onBlur': field.onBlur, 30 | 'onUpdate:modelValue': field['onUpdate:modelValue'], 31 | } 32 | } 33 | ``` 34 | 35 | ```vue 36 | 37 | 38 | 39 | 40 | ``` 41 | ## Example input 42 | 43 | Visit the [best practice page](../best-practices/custom-input.md) to view an example of a custom input consuming the Field API. 44 | 45 | 46 | ### Field object 47 | 48 | | State | Type | Description | 49 | | --------------- | --------- | ----------------------------------------------------------------- | 50 | | errors | `ComputedRef[]>` | Current errors on the field | 51 | | isChanged | `ComputedRef` | Boolean indicating if the value of the field in changed. 52 | | isDirty | `ComputedRef` | Boolean indicating if the field is currently dirty | 53 | | isTouched | `ComputedRef` | Boolean indicating if the field is touched | 54 | | isValid | `ComputedRef` | Boolean indicating if the field is currently valid | 55 | | modelValue | `ComputedRef` | The value of the field, used to bind v-model | 56 | | value | `ComputedRef` | The value of the field, alias for modelValue | 57 | | onBlur | `Function` | Handles the onBlur event, used to bind the event | 58 | | onChange | `Function` | Handles the onChange event, used to bind the event | 59 | | onUpdate:modelValue | `Function` | Handles the updating of modelValue event, used to bind v-model | 60 | | setValue | `Function` | Sets modelValue manually | 61 | | register | `Function` | Function to register any fields under this field | 62 | 63 | 64 | ## Type definitions 65 | 66 | ::: code-group 67 | 68 | ```ts [Field] 69 | /** 70 | * Represents a form field. 71 | * 72 | * @typeparam TValue The type of the field value. 73 | * @typeparam TDefaultValue The type of the field default value. 74 | */ 75 | export interface Field { 76 | /** 77 | * The current path of the field. This can change if fields are unregistered. 78 | */ 79 | _path: ComputedRef 80 | /** 81 | * The unique id of the field. 82 | */ 83 | _id: string 84 | /** 85 | * Internal flag to track if the field has been touched (blurred). 86 | */ 87 | _isTouched: Ref 88 | /** 89 | * The current value of the field. 90 | */ 91 | modelValue: ComputedRef 92 | /** 93 | * Updates the current value of the field. 94 | * 95 | * @param value The new value of the field. 96 | */ 97 | 'onUpdate:modelValue': (value: TValue | null) => void 98 | 99 | /** 100 | * The current value of the field. 101 | * 102 | * This is an alias of `attrs.modelValue`. 103 | */ 104 | value: ComputedRef 105 | /** 106 | * The errors associated with the field and its children. 107 | */ 108 | errors: ComputedRef[]> 109 | /** 110 | * The raw errors associated with the field and its children. 111 | */ 112 | rawErrors: ComputedRef 113 | /** 114 | * Indicates whether the field has any errors. 115 | */ 116 | isValid: ComputedRef 117 | /** 118 | * Indicates whether the field has been touched (blurred). 119 | */ 120 | isTouched: ComputedRef 121 | /** 122 | * Indicates whether the field has been changed. 123 | * This flag will remain `true` even if the field value is set back to its initial value. 124 | */ 125 | isChanged: Ref 126 | /** 127 | * Indicates whether the field value is different from its initial value. 128 | */ 129 | isDirty: ComputedRef 130 | /** 131 | * Called when the field input is blurred. 132 | */ 133 | onBlur: () => void 134 | /** 135 | * Called when the field input value is changed. 136 | */ 137 | onChange: () => void 138 | 139 | /** 140 | * Sets the current value of the field. 141 | * 142 | * This is an alias of `onUpdate:modelValue`. 143 | * 144 | * @param value The new value of the field. 145 | */ 146 | setValue: (value: TValue | null) => void 147 | register: < 148 | TValueAsFieldValues extends TValue extends FieldValues ? TValue : never, 149 | TChildPath extends FieldPath, 150 | TChildDefaultValue extends FieldPathValue | undefined, 151 | >( 152 | path: TChildPath, 153 | defaultValue?: TChildDefaultValue 154 | ) => Field< 155 | FieldPathValue, 156 | TChildDefaultValue 157 | > 158 | 159 | registerArray: < 160 | TValueAsFieldValues extends TValue extends FieldValues ? TValue : never, 161 | TPath extends FieldPath, 162 | TChildDefaultValue extends FieldPathValue | undefined, 163 | >( 164 | path: TPath, 165 | defaultValue?: TChildDefaultValue, 166 | ) => FieldArray> 167 | } 168 | ``` 169 | 170 | ::: 171 | ## Source 172 | 173 | [Source](https://github.com/wisemen-digital/vue-formango/blob/main/src/lib/useForm.ts) - [Types](https://github.com/wouterlms/forms/blob/main/src/types/form.type.ts) -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/no-empty-object-type */ 2 | /* eslint-disable no-implicit-coercion */ 3 | /* eslint-disable no-nested-ternary */ 4 | /* eslint-disable unicorn/no-keyword-prefix */ 5 | function isObject(value: unknown): boolean { 6 | return value !== null && typeof value === 'object' 7 | } 8 | 9 | function isNullOrUndefined(value: unknown): value is null | undefined { 10 | return value === null || value === undefined 11 | } 12 | 13 | function isUndefined(val: unknown): val is undefined { 14 | return val === undefined 15 | } 16 | 17 | function isEmptyArray(obj: unknown[]): boolean { 18 | for (const key in obj) { 19 | if (!isUndefined(obj[key])) { 20 | return false 21 | } 22 | } 23 | 24 | return true 25 | } 26 | 27 | function isEmptyObject(value: Record) { 28 | return isObject(value) && Object.keys(value).length === 0 29 | } 30 | 31 | function baseGet(object: any, updatePath: (number | string)[]) { 32 | const length = updatePath.slice(0, -1).length 33 | let index = 0 34 | 35 | while (index < length) { 36 | object = isUndefined(object) ? index++ : object[updatePath[index++]] 37 | } 38 | 39 | return object 40 | } 41 | 42 | export function set( 43 | object: Record, 44 | path: string, 45 | value?: unknown, 46 | ) { 47 | let index = -1 48 | const arrayPath = path.split('.') 49 | const length = arrayPath.length 50 | const lastIndex = length - 1 51 | 52 | while (++index < length) { 53 | const key = arrayPath[index] 54 | let newValue = value 55 | 56 | if (index !== lastIndex) { 57 | const objValue = object[key] 58 | 59 | newValue 60 | = (isObject(objValue) || Array.isArray(objValue)) 61 | ? objValue 62 | : (!Number.isNaN(+arrayPath[index + 1]) 63 | ? [] 64 | : {}) 65 | } 66 | 67 | object[key] = newValue 68 | object = object[key] as Record 69 | } 70 | 71 | return object 72 | } 73 | 74 | export function get(obj: T, path: string, defaultValue?: unknown): any { 75 | const arrayPath = path.split('.') 76 | 77 | const result = arrayPath.reduce( 78 | (result, key) => 79 | isNullOrUndefined(result) ? result : result[key as keyof {}], 80 | obj, 81 | ) 82 | 83 | if (isNullOrUndefined(obj)) { 84 | return undefined 85 | } 86 | 87 | return (isUndefined(result) || result === obj) 88 | ? (isUndefined(obj[path as keyof T]) 89 | ? defaultValue 90 | : obj[path as keyof T]) 91 | : result 92 | } 93 | 94 | export function unset(object: any, path: string) { 95 | const arrayPath = path.split('.') 96 | 97 | const childObject = arrayPath.length === 1 98 | ? object 99 | : baseGet(object, arrayPath) 100 | 101 | const index = arrayPath.length - 1 102 | const key = arrayPath[index] 103 | 104 | if (childObject) { 105 | if (Array.isArray(childObject)) { 106 | childObject.splice(+key, 1) 107 | } 108 | else if (isObject(childObject)) { 109 | const value = childObject[key] 110 | 111 | if (!Array.isArray(value)) { 112 | delete childObject[key] 113 | } 114 | } 115 | } 116 | 117 | if ( 118 | index !== 0 119 | && ((isObject(childObject) && isEmptyObject(childObject)) 120 | || (Array.isArray(childObject) && isEmptyArray(childObject))) 121 | ) { 122 | unset(object, arrayPath.slice(0, -1).join('.')) 123 | } 124 | 125 | return object 126 | } 127 | 128 | export function generateId(): string { 129 | let id = '' 130 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 131 | 132 | for (let i = 0; i < 10; i += 1) { 133 | id += chars.charAt(Math.floor(Math.random() * chars.length)) 134 | } 135 | 136 | return `form-${id}` 137 | } 138 | 139 | const x = 0 140 | 141 | export function generateUuid(): string { 142 | const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 143 | const r = Math.random() * 16 | x 144 | const v = c === 'x' ? r : (r & 0x3 | 0x8) 145 | 146 | return v.toString(16) 147 | }) 148 | 149 | return uuid 150 | } 151 | 152 | type ThrottledFunction any> = (...args: Parameters) => ReturnType 153 | export function throttle any>(func: T, limit: number): ThrottledFunction { 154 | let inThrottle: boolean 155 | let lastResult: ReturnType 156 | 157 | return function (this: any, ...args: any[]): ReturnType { 158 | // eslint-disable-next-line ts/no-this-alias 159 | const context = this 160 | 161 | if (!inThrottle) { 162 | inThrottle = true 163 | 164 | setTimeout(() => (inThrottle = false), limit) 165 | 166 | lastResult = func.apply(context, args) 167 | } 168 | 169 | return lastResult 170 | } 171 | } 172 | 173 | export function isSubPath({ 174 | childPath, parentPath, 175 | }: { 176 | childPath: string 177 | parentPath: string 178 | }): { isPart: boolean 179 | relativePath?: string } { 180 | const childSegments = childPath.split('.') 181 | const parentSegments = parentPath.split('.') 182 | 183 | if (childSegments.length <= parentSegments.length) { 184 | return { isPart: false } 185 | } 186 | 187 | for (const [ 188 | i, 189 | parentSegment, 190 | ] of parentSegments.entries()) { 191 | if (childSegments[i] !== parentSegment) { 192 | return { isPart: false } 193 | } 194 | } 195 | 196 | const relativePath = childSegments.slice(parentSegments.length).join('.') 197 | 198 | return { 199 | isPart: true, 200 | relativePath, 201 | } 202 | } 203 | 204 | export function isChildOfPath({ 205 | childPath, parentPath, 206 | }: { 207 | childPath: string 208 | parentPath: string 209 | }): { isPart: boolean 210 | relativePath?: string } { 211 | // if childPath is a.b.c and parent path is a.b, it should return true because it is a child of that path 212 | // but if parent path is a.b and childPath is a.b.c, it should return false 213 | 214 | const childSegments = childPath.split('.') 215 | const parentSegments = parentPath.split('.') 216 | 217 | if (childSegments.length <= parentSegments.length) { 218 | return { isPart: false } 219 | } 220 | 221 | for (const [ 222 | i, 223 | parentSegment, 224 | ] of parentSegments.entries()) { 225 | if (childSegments[i] !== parentSegment) { 226 | return { isPart: false } 227 | } 228 | } 229 | 230 | const relativePath = childSegments.slice(parentSegments.length).join('.') 231 | 232 | return { 233 | isPart: true, 234 | relativePath, 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /docs/api/field-array.md: -------------------------------------------------------------------------------- 1 | # Array field 2 | 3 | Array field that gets returned when you register an array with the registerArray function. 4 | 5 | ## Usage Default 6 | 7 | ::: code-group 8 | 9 | ```vue [ExampleArray.vue] 10 | 30 | 31 | 32 | 33 | 40 | 41 | Add email 42 | 43 | 44 | 45 | ``` 46 | 47 | ```vue [ExampleArrayField.vue] 48 | 65 | 66 | 67 | 68 | {{ index }} 69 | 70 | Remove 71 | 72 | 73 | 74 | 75 | ``` 76 | 77 | ```ts [example.model.ts] 78 | import { z } from 'zod' 79 | 80 | export const emailSchema = z.string().email() 81 | export const arrayFormSchema = z.object({ 82 | emails: z.array(emailSchema), 83 | }) 84 | 85 | export type ArrayForm = z.infer 86 | export type Email = z.infer 87 | ``` 88 | 89 | ::: 90 | 91 | ### Array field object 92 | 93 | | State | Type | Description | 94 | | --------------- | --------- | ----------------------------------------------------------------- | 95 | | fields | `Function` | Array of unique indexes which should be used in a v-for to render all the fields | 96 | | append | `Function` | Adds a field to the end of the array | 97 | | prepend | `Function` | Adds a field to the start of the array | 98 | | insert | `Function` | Adds a field to the a specified location of the array | 99 | | pop | `Function` | Removes the last field of the array | 100 | | remove | `Function` | Removes a field of the array at a specified position | 101 | | shift | `Function` | Removes the first field of the array | 102 | | move | `Function` | Swaps the position of two elements of the array | 103 | | empty | `Function` | Empties the array | 104 | | errors | `Object` | Current errors on the array | 105 | | isDirty | `ComputedRef` | Boolean indicating if the array is currently dirty | 106 | | isValid | `ComputedRef` | Boolean indicating if the array is currently valid | 107 | | modelValue | `ComputedRef` | The value of the array | 108 | | setValue | `Function` | Sets the value of the array | 109 | | register | `Function` | Function to register any fields under this field | 110 | 111 | 112 | ## Type definitions 113 | 114 | ```ts 115 | /** 116 | * Represents a form field array. 117 | * 118 | * @typeparam T The type of the form schema. 119 | */ 120 | export interface FieldArray { 121 | /** 122 | * The current path of the field. This can change if fields are unregistered. 123 | */ 124 | _path: ComputedRef 125 | /** 126 | * The unique id of the field. 127 | */ 128 | _id: string 129 | /** 130 | * Array of unique ids of the fields. 131 | */ 132 | fields: Ref 133 | /** 134 | * The errors associated with the field and its children. 135 | */ 136 | errors: ComputedRef[]> 137 | /** 138 | * The raw errors associated with the field and its children. 139 | */ 140 | rawErrors: ComputedRef 141 | /** 142 | * The current value of the field. 143 | */ 144 | modelValue: ComputedRef 145 | /** 146 | * Indicates whether the field has any errors. 147 | */ 148 | isValid: ComputedRef 149 | /** 150 | * Indicates whether the field has been touched (blurred). 151 | */ 152 | isTouched: ComputedRef 153 | /** 154 | * Indicates whether the field value is different from its initial value. 155 | */ 156 | isDirty: ComputedRef 157 | /** 158 | * The current value of the field. 159 | * 160 | * This is an alias of `attrs.modelValue`. 161 | */ 162 | value: ComputedRef 163 | /** 164 | * Insert a new field at the given index. 165 | * @param index The index of the field to insert. 166 | */ 167 | insert: (index: number, value?: ArrayElement) => void 168 | /** 169 | * Remove a field at the given index. 170 | * @param index The index of the field to remove. 171 | */ 172 | remove: (index: number) => void 173 | /** 174 | * Add a new field at the beginning of the array. 175 | */ 176 | prepend: (value?: ArrayElement) => void 177 | /** 178 | * Add a new field at the end of the array. 179 | */ 180 | append: (value?: ArrayElement) => void 181 | /** 182 | * Remove the last field of the array. 183 | */ 184 | pop: () => void 185 | /** 186 | * Remove the first field of the array. 187 | */ 188 | shift: () => void 189 | /** 190 | * Move a field from one index to another. 191 | */ 192 | move: (from: number, to: number) => void 193 | /** 194 | * Empty the array. 195 | */ 196 | empty: () => void 197 | /** 198 | * Set the current value of the field. 199 | */ 200 | setValue: (value: TValue) => void 201 | 202 | register: < 203 | TChildPath extends TValue extends FieldValues ? FieldPath : never, 204 | TChildDefaultValue extends TValue extends FieldValues ? FieldPathValue | undefined : never, 205 | >( 206 | path: TChildPath, 207 | defaultValue?: TChildDefaultValue 208 | ) => TValue extends FieldValues ? Field, any> : never 209 | 210 | registerArray: : never>( 211 | path: TPath 212 | ) => TValue extends FieldValues ? FieldArray> : never 213 | } 214 | ``` 215 | ## Source 216 | 217 | [Source](https://github.com/wouterlms/forms/blob/main/src/composables/useForm.ts) - [Types](https://github.com/wouterlms/forms/blob/main/src/types/form.type.ts) -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js: -------------------------------------------------------------------------------- 1 | import { 2 | BaseTransition, 3 | BaseTransitionPropsValidators, 4 | Comment, 5 | DeprecationTypes, 6 | EffectScope, 7 | ErrorCodes, 8 | ErrorTypeStrings, 9 | Fragment, 10 | KeepAlive, 11 | ReactiveEffect, 12 | Static, 13 | Suspense, 14 | Teleport, 15 | Text, 16 | TrackOpTypes, 17 | Transition, 18 | TransitionGroup, 19 | TriggerOpTypes, 20 | VueElement, 21 | assertNumber, 22 | callWithAsyncErrorHandling, 23 | callWithErrorHandling, 24 | camelize, 25 | capitalize, 26 | cloneVNode, 27 | compatUtils, 28 | compile, 29 | computed, 30 | createApp, 31 | createBaseVNode, 32 | createBlock, 33 | createCommentVNode, 34 | createElementBlock, 35 | createHydrationRenderer, 36 | createPropsRestProxy, 37 | createRenderer, 38 | createSSRApp, 39 | createSlots, 40 | createStaticVNode, 41 | createTextVNode, 42 | createVNode, 43 | customRef, 44 | defineAsyncComponent, 45 | defineComponent, 46 | defineCustomElement, 47 | defineEmits, 48 | defineExpose, 49 | defineModel, 50 | defineOptions, 51 | defineProps, 52 | defineSSRCustomElement, 53 | defineSlots, 54 | devtools, 55 | effect, 56 | effectScope, 57 | getCurrentInstance, 58 | getCurrentScope, 59 | getTransitionRawChildren, 60 | guardReactiveProps, 61 | h, 62 | handleError, 63 | hasInjectionContext, 64 | hydrate, 65 | initCustomFormatter, 66 | initDirectivesForSSR, 67 | inject, 68 | isMemoSame, 69 | isProxy, 70 | isReactive, 71 | isReadonly, 72 | isRef, 73 | isRuntimeOnly, 74 | isShallow, 75 | isVNode, 76 | markRaw, 77 | mergeDefaults, 78 | mergeModels, 79 | mergeProps, 80 | nextTick, 81 | normalizeClass, 82 | normalizeProps, 83 | normalizeStyle, 84 | onActivated, 85 | onBeforeMount, 86 | onBeforeUnmount, 87 | onBeforeUpdate, 88 | onDeactivated, 89 | onErrorCaptured, 90 | onMounted, 91 | onRenderTracked, 92 | onRenderTriggered, 93 | onScopeDispose, 94 | onServerPrefetch, 95 | onUnmounted, 96 | onUpdated, 97 | openBlock, 98 | popScopeId, 99 | provide, 100 | proxyRefs, 101 | pushScopeId, 102 | queuePostFlushCb, 103 | reactive, 104 | readonly, 105 | ref, 106 | registerRuntimeCompiler, 107 | render, 108 | renderList, 109 | renderSlot, 110 | resolveComponent, 111 | resolveDirective, 112 | resolveDynamicComponent, 113 | resolveFilter, 114 | resolveTransitionHooks, 115 | setBlockTracking, 116 | setDevtoolsHook, 117 | setTransitionHooks, 118 | shallowReactive, 119 | shallowReadonly, 120 | shallowRef, 121 | ssrContextKey, 122 | ssrUtils, 123 | stop, 124 | toDisplayString, 125 | toHandlerKey, 126 | toHandlers, 127 | toRaw, 128 | toRef, 129 | toRefs, 130 | toValue, 131 | transformVNodeArgs, 132 | triggerRef, 133 | unref, 134 | useAttrs, 135 | useCssModule, 136 | useCssVars, 137 | useModel, 138 | useSSRContext, 139 | useSlots, 140 | useTransitionState, 141 | vModelCheckbox, 142 | vModelDynamic, 143 | vModelRadio, 144 | vModelSelect, 145 | vModelText, 146 | vShow, 147 | version, 148 | warn, 149 | watch, 150 | watchEffect, 151 | watchPostEffect, 152 | watchSyncEffect, 153 | withAsyncContext, 154 | withCtx, 155 | withDefaults, 156 | withDirectives, 157 | withKeys, 158 | withMemo, 159 | withModifiers, 160 | withScopeId 161 | } from "./chunk-JI5EUS42.js"; 162 | export { 163 | BaseTransition, 164 | BaseTransitionPropsValidators, 165 | Comment, 166 | DeprecationTypes, 167 | EffectScope, 168 | ErrorCodes, 169 | ErrorTypeStrings, 170 | Fragment, 171 | KeepAlive, 172 | ReactiveEffect, 173 | Static, 174 | Suspense, 175 | Teleport, 176 | Text, 177 | TrackOpTypes, 178 | Transition, 179 | TransitionGroup, 180 | TriggerOpTypes, 181 | VueElement, 182 | assertNumber, 183 | callWithAsyncErrorHandling, 184 | callWithErrorHandling, 185 | camelize, 186 | capitalize, 187 | cloneVNode, 188 | compatUtils, 189 | compile, 190 | computed, 191 | createApp, 192 | createBlock, 193 | createCommentVNode, 194 | createElementBlock, 195 | createBaseVNode as createElementVNode, 196 | createHydrationRenderer, 197 | createPropsRestProxy, 198 | createRenderer, 199 | createSSRApp, 200 | createSlots, 201 | createStaticVNode, 202 | createTextVNode, 203 | createVNode, 204 | customRef, 205 | defineAsyncComponent, 206 | defineComponent, 207 | defineCustomElement, 208 | defineEmits, 209 | defineExpose, 210 | defineModel, 211 | defineOptions, 212 | defineProps, 213 | defineSSRCustomElement, 214 | defineSlots, 215 | devtools, 216 | effect, 217 | effectScope, 218 | getCurrentInstance, 219 | getCurrentScope, 220 | getTransitionRawChildren, 221 | guardReactiveProps, 222 | h, 223 | handleError, 224 | hasInjectionContext, 225 | hydrate, 226 | initCustomFormatter, 227 | initDirectivesForSSR, 228 | inject, 229 | isMemoSame, 230 | isProxy, 231 | isReactive, 232 | isReadonly, 233 | isRef, 234 | isRuntimeOnly, 235 | isShallow, 236 | isVNode, 237 | markRaw, 238 | mergeDefaults, 239 | mergeModels, 240 | mergeProps, 241 | nextTick, 242 | normalizeClass, 243 | normalizeProps, 244 | normalizeStyle, 245 | onActivated, 246 | onBeforeMount, 247 | onBeforeUnmount, 248 | onBeforeUpdate, 249 | onDeactivated, 250 | onErrorCaptured, 251 | onMounted, 252 | onRenderTracked, 253 | onRenderTriggered, 254 | onScopeDispose, 255 | onServerPrefetch, 256 | onUnmounted, 257 | onUpdated, 258 | openBlock, 259 | popScopeId, 260 | provide, 261 | proxyRefs, 262 | pushScopeId, 263 | queuePostFlushCb, 264 | reactive, 265 | readonly, 266 | ref, 267 | registerRuntimeCompiler, 268 | render, 269 | renderList, 270 | renderSlot, 271 | resolveComponent, 272 | resolveDirective, 273 | resolveDynamicComponent, 274 | resolveFilter, 275 | resolveTransitionHooks, 276 | setBlockTracking, 277 | setDevtoolsHook, 278 | setTransitionHooks, 279 | shallowReactive, 280 | shallowReadonly, 281 | shallowRef, 282 | ssrContextKey, 283 | ssrUtils, 284 | stop, 285 | toDisplayString, 286 | toHandlerKey, 287 | toHandlers, 288 | toRaw, 289 | toRef, 290 | toRefs, 291 | toValue, 292 | transformVNodeArgs, 293 | triggerRef, 294 | unref, 295 | useAttrs, 296 | useCssModule, 297 | useCssVars, 298 | useModel, 299 | useSSRContext, 300 | useSlots, 301 | useTransitionState, 302 | vModelCheckbox, 303 | vModelDynamic, 304 | vModelRadio, 305 | vModelSelect, 306 | vModelText, 307 | vShow, 308 | version, 309 | warn, 310 | watch, 311 | watchEffect, 312 | watchPostEffect, 313 | watchSyncEffect, 314 | withAsyncContext, 315 | withCtx, 316 | withDefaults, 317 | withDirectives, 318 | withKeys, 319 | withMemo, 320 | withModifiers, 321 | withScopeId 322 | }; 323 | //# sourceMappingURL=vue.js.map 324 | -------------------------------------------------------------------------------- /src/types/eager.type.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArrayKey, 3 | IsTuple, 4 | TupleKeys, 5 | } from './common.type' 6 | import type { 7 | BrowserNativeObject, 8 | IsAny, 9 | IsEqual, 10 | Primitive, 11 | } from './utils.type' 12 | 13 | export type FieldValues = Record 14 | 15 | /** 16 | * Helper function to break apart T1 and check if any are equal to T2 17 | * 18 | * See {@link IsEqual} 19 | */ 20 | type AnyIsEqual = T1 extends T2 21 | ? IsEqual extends true 22 | ? true 23 | : never 24 | : never 25 | 26 | /** 27 | * Helper type for recursively constructing paths through a type. 28 | * This actually constructs the strings and recurses into nested 29 | * object types. 30 | * 31 | * See {@link Path} 32 | */ 33 | type PathImpl = V extends 34 | | BrowserNativeObject 35 | | Primitive 36 | ? `${K}` 37 | : // Check so that we don't recurse into the same type 38 | // by ensuring that the types are mutually assignable 39 | // mutually required to avoid false positives of subtypes 40 | true extends AnyIsEqual 41 | ? `${K}` 42 | : `${K}.${PathInternal}` | `${K}` 43 | 44 | /** 45 | * Helper type for recursively constructing paths through a type. 46 | * This obsucres the internal type param TraversedTypes from exported contract. 47 | * 48 | * See {@link Path} 49 | */ 50 | type PathInternal = T extends ReadonlyArray 51 | ? IsTuple extends true 52 | ? { 53 | [K in TupleKeys]-?: PathImpl; 54 | }[TupleKeys] 55 | : PathImpl 56 | : { 57 | [K in keyof T]-?: PathImpl; 58 | }[keyof T] 59 | 60 | /** 61 | * Type which eagerly collects all paths through a type 62 | * @typeParam T - type which should be introspected 63 | * @example 64 | * ``` 65 | * Path<{foo: {bar: string}}> = 'foo' | 'foo.bar' 66 | * ``` 67 | */ 68 | // We want to explode the union type and process each individually 69 | // so assignable types don't leak onto the stack from the base. 70 | export type Path = T extends any ? PathInternal : never 71 | 72 | /** 73 | * See {@link Path} 74 | */ 75 | export type FieldPath = Path 76 | 77 | /** 78 | * Helper type for recursively constructing paths through a type. 79 | * This actually constructs the strings and recurses into nested 80 | * object types. 81 | * 82 | * See {@link ArrayPath} 83 | */ 84 | type ArrayPathImpl = V extends 85 | | BrowserNativeObject 86 | | Primitive 87 | ? IsAny extends true 88 | ? string 89 | : never 90 | : V extends ReadonlyArray 91 | ? U extends BrowserNativeObject | Primitive 92 | ? IsAny extends true 93 | ? string 94 | : never 95 | : // Check so that we don't recurse into the same type 96 | // by ensuring that the types are mutually assignable 97 | // mutually required to avoid false positives of subtypes 98 | true extends AnyIsEqual 99 | ? never 100 | : `${K}.${ArrayPathInternal}` | `${K}` 101 | : true extends AnyIsEqual 102 | ? never 103 | : `${K}.${ArrayPathInternal}` 104 | 105 | /** 106 | * Helper type for recursively constructing paths through a type. 107 | * This obsucres the internal type param TraversedTypes from exported contract. 108 | * 109 | * See {@link ArrayPath} 110 | */ 111 | type ArrayPathInternal = T extends ReadonlyArray 112 | ? IsTuple extends true 113 | ? { 114 | [K in TupleKeys]-?: ArrayPathImpl; 115 | }[TupleKeys] 116 | : ArrayPathImpl 117 | : { 118 | [K in keyof T]-?: ArrayPathImpl; 119 | }[keyof T] 120 | 121 | /** 122 | * Type which eagerly collects all paths through a type which point to an array 123 | * type. 124 | * @typeParam T - type which should be introspected. 125 | * @example 126 | * ``` 127 | * Path<{foo: {bar: string[], baz: number[]}}> = 'foo.bar' | 'foo.baz' 128 | * ``` 129 | */ 130 | // We want to explode the union type and process each individually 131 | // so assignable types don't leak onto the stack from the base. 132 | export type ArrayPath = T extends any ? ArrayPathInternal : never 133 | 134 | /** 135 | * See {@link ArrayPath} 136 | */ 137 | export type FieldArrayPath = 138 | ArrayPath 139 | 140 | /** 141 | * Type to evaluate the type which the given path points to. 142 | * @typeParam T - deeply nested type which is indexed by the path 143 | * @typeParam P - path into the deeply nested type 144 | * @example 145 | * ``` 146 | * PathValue<{foo: {bar: string}}, 'foo.bar'> = string 147 | * PathValue<[number, string], '1'> = string 148 | * ``` 149 | */ 150 | export type PathValue | Path> = T extends any 151 | ? P extends `${infer K}.${infer R}` 152 | ? K extends keyof T 153 | ? R extends Path 154 | ? PathValue 155 | : never 156 | : K extends `${ArrayKey}` 157 | ? T extends ReadonlyArray 158 | ? PathValue> 159 | : never 160 | : never 161 | : P extends keyof T 162 | ? T[P] 163 | : P extends `${ArrayKey}` 164 | ? T extends ReadonlyArray 165 | ? V 166 | : never 167 | : never 168 | : never 169 | 170 | /** 171 | * See {@link PathValue} 172 | */ 173 | export type FieldPathValue< 174 | TFieldValues, 175 | TFieldPath extends FieldPath, 176 | > = PathValue 177 | 178 | /** 179 | * See {@link PathValue} 180 | */ 181 | export type FieldArrayPathValue< 182 | TFieldValues extends FieldValues, 183 | TFieldArrayPath extends FieldArrayPath, 184 | > = PathValue 185 | 186 | /** 187 | * Type to evaluate the type which the given paths point to. 188 | * @typeParam TFieldValues - field values which are indexed by the paths 189 | * @typeParam TPath - paths into the deeply nested field values 190 | * @example 191 | * ``` 192 | * FieldPathValues<{foo: {bar: string}}, ['foo', 'foo.bar']> 193 | * = [{bar: string}, string] 194 | * ``` 195 | */ 196 | export type FieldPathValues< 197 | TFieldValues extends FieldValues, 198 | TPath extends Array> | ReadonlyArray>, 199 | > = {} & { 200 | [K in keyof TPath]: FieldPathValue< 201 | TFieldValues, 202 | TPath[K] & FieldPath 203 | >; 204 | } 205 | 206 | /** 207 | * Type which eagerly collects all paths through a fieldType that matches a give type 208 | * @typeParam TFieldValues - field values which are indexed by the paths 209 | * @typeParam TValue - the value you want to match into each type 210 | * @example 211 | * ```typescript 212 | * FieldPathByValue<{foo: {bar: number}, baz: number, bar: string}, number> 213 | * = 'foo.bar' | 'baz' 214 | * ``` 215 | */ 216 | export type FieldPathByValue = { 217 | [Key in FieldPath]: FieldPathValue< 218 | TFieldValues, 219 | Key 220 | > extends TValue 221 | ? Key 222 | : never; 223 | }[FieldPath] 224 | -------------------------------------------------------------------------------- /docs/api/useForm.md: -------------------------------------------------------------------------------- 1 | # useForm 2 | 3 | Initializes a form from a Zod schema and returns a form object and a onSubmitForm callback function. 4 | 5 | ### Form object 6 | 7 | | State | Type | Description | 8 | | --------------- | --------- | ----------------------------------------------------------------- | 9 | | errors | `ComputedRef[]>` | Current errors on the form, refer to Zod | 10 | | hasAttemptedToSubmit | `ComputedRef` | Boolean indicating if the form has been submitted. 11 | | isDirty | `ComputedRef` | Boolean indicating if the form is currently dirty (does not get set if you enter initial values in the form) | 12 | | isSubmitting | `ComputedRef` | Boolean indicating if the form is currently submitting | 13 | | isValid | `ComputedRef`| Boolean indicating if the form is currently valid | 14 | | register | `Function` | Register function to register a field, default value is optional. E.g: ```form.register('email', 'default email')```| 15 | | registerArray | `Function` | Register function to register an array field. E.g: ```form.registerArray('emails')``` | 16 | | unregister | `Function` | Unregister function to unregister a field. E.g: ```form.unregister('email')``` | 17 | | addErrors | `Function` | Manually set errors on fields. | 18 | | setValues | `Function` | Manually set values on fields. | 19 | | state | `ComputedRef` | Current state of the form | 20 | | submit | `Function` | Submit function to submit the form, which triggers the onSubmitForm callback if it is valid | 21 | 22 | ## Usage Advanced 23 | 24 | ```ts 25 | import { z } from 'zod' 26 | import { useForm } from 'formango' 27 | 28 | const exampleSchema = z.object({ 29 | name: z.string().min(3).max(255), 30 | email: z.string().email(), 31 | password: z.string().min(8).max(255), 32 | emails: z.array(z.string().email()), 33 | }) 34 | 35 | const form = useForm( 36 | { 37 | schema: exampleSchema, 38 | onSubmit: (data) => { 39 | /* Data type is inferred from the schema 40 | { 41 | email: string 42 | password: string 43 | name: string 44 | emails: string[] 45 | } 46 | */ 47 | // Handle form submit 48 | }, 49 | initialData: { 50 | email: '', 51 | password: '', 52 | name: 'Robbe', 53 | emails: [], 54 | }, 55 | } 56 | ) 57 | 58 | const email = form.register('email', 'default email') 59 | const emails = form.registerArray('emails') 60 | onUnmounted(() => { 61 | form.unregister('email') 62 | }) 63 | 64 | // Manually set errors on fields, for example if you get errors from your backend 65 | form.addErrors([{ 66 | path: 'email', 67 | message: 'Invalid email', 68 | }]) 69 | 70 | // Manually set values on fields 71 | form.setValues({ 72 | email: '', 73 | password: '', 74 | name: 'Robbe', 75 | emails: ['email@test.be'] 76 | }) 77 | 78 | /* Current state of the form 79 | { 80 | email: string 81 | password: string 82 | name: string 83 | emails: string[] 84 | }, 85 | */ 86 | form.state.value 87 | 88 | // Triggers onSubmitForm callback if form is valid 89 | form.submit() 90 | ``` 91 | 92 | ## Type definitions 93 | 94 | ::: code-group 95 | ```ts [UseFormOptions] 96 | interface UseFormOptions { 97 | /** 98 | * The schema of the form. 99 | */ 100 | schema: TSchema 101 | /** 102 | * The initial state of the form 103 | */ 104 | initialState?: MaybeRefOrGetter>> 105 | /** 106 | * Called when the form is valid and submitted. 107 | * @param data The current form data. 108 | */ 109 | onSubmit: (data: StandardSchemaV1.InferOutput) => void 110 | /** 111 | * Called when the form is attempted to be submitted, but is invalid. 112 | * Only called for client-side validation. 113 | */ 114 | onSubmitError?: ({ data, errors }: { data: DeepPartial>>; errors: FormattedError>[] }) => void 115 | } 116 | ``` 117 | 118 | ```ts [Form] 119 | export interface Form { 120 | /** 121 | * Internal id of the form, to track it in the devtools. 122 | */ 123 | _id: string 124 | /** 125 | * The current state of the form. 126 | */ 127 | state: ComputedRef>>> 128 | /** 129 | * The collection of all registered fields' errors. 130 | */ 131 | errors: ComputedRef>[]> 132 | /** 133 | * The raw errors associated with the field and its children. 134 | */ 135 | rawErrors: ComputedRef 136 | 137 | /** 138 | * Indicates whether the form is dirty or not. 139 | * 140 | * A form is considered dirty if any of its fields have been changed. 141 | */ 142 | isDirty: ComputedRef 143 | /** 144 | * Indicates whether the form is currently submitting or not. 145 | */ 146 | isSubmitting: ComputedRef 147 | /** 148 | * Indicates whether the form has been attempted to submit. 149 | */ 150 | hasAttemptedToSubmit: ComputedRef 151 | /** 152 | * Indicates whether the form is currently valid or not. 153 | * 154 | * A form is considered valid if all of its fields are valid. 155 | */ 156 | isValid: ComputedRef 157 | /** 158 | * Registers a new form field. 159 | * 160 | * @returns A `Field` instance that can be used to interact with the field. 161 | */ 162 | register: Register> 163 | /** 164 | * Registers a new form field array. 165 | * 166 | * @returns A `FieldArray` instance that can be used to interact with the field array. 167 | */ 168 | registerArray: RegisterArray 169 | /** 170 | * Unregisters a previously registered field. 171 | * 172 | * @param path The path of the field to unregister. 173 | */ 174 | unregister: Unregister 175 | /** 176 | * Sets errors in the form. 177 | * 178 | * @param errors The new errors for the form fields. 179 | */ 180 | addErrors: (errors: FormattedError>[]) => void 181 | /** 182 | * Sets values in the form. 183 | * 184 | * @param values The new values for the form fields. 185 | */ 186 | setValues: (values: DeepPartial>) => void 187 | /** 188 | * Submits the form. 189 | * 190 | * @returns A promise that resolves once the form has been successfully submitted. 191 | */ 192 | submit: () => Promise 193 | /** 194 | * Resets the form to the initial state. 195 | */ 196 | reset: () => void 197 | } 198 | ``` 199 | 200 | ```ts [Register] 201 | // Returns a Field, read the Field API documentation for more info 202 | export type Register = < 203 | TPath extends FieldPath, 204 | TValue extends FieldPathValue, 205 | TDefaultValue extends FieldPathValue | undefined, 206 | >(field: TPath, defaultValue?: TDefaultValue) => Field 207 | ``` 208 | 209 | ```ts [RegisterArray] 210 | // Returns a FieldArray, read the FieldArray API documentation for more info 211 | export type RegisterArray = < 212 | TPath extends FieldPath>, 213 | TValue extends FieldPathValue, TPath>, 214 | TDefaultValue extends FieldPathValue, TPath> | undefined, 215 | >(field: TPath, defaultValue?: TDefaultValue) => FieldArray 216 | ``` 217 | 218 | ```ts [Unregister] 219 | export type Unregister = < 220 | P extends FieldPath>, 221 | >(field: P) => void 222 | ``` 223 | 224 | ::: 225 | 226 | ## Source 227 | 228 | [Source](https://github.com/wisemen-digital/vue-formango/blob/main/src/lib/useForm.ts) - [Types](https://github.com/wouterlms/forms/blob/main/src/types/form.type.ts) -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/env.js", "../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/const.js", "../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/time.js", "../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/proxy.js", "../../../../node_modules/.pnpm/@vue+devtools-api@6.5.0/node_modules/@vue/devtools-api/lib/esm/index.js"], 4 | "sourcesContent": ["export function getDevtoolsGlobalHook() {\n return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;\n}\nexport function getTarget() {\n // @ts-ignore\n return (typeof navigator !== 'undefined' && typeof window !== 'undefined')\n ? window\n : typeof global !== 'undefined'\n ? global\n : {};\n}\nexport const isProxyAvailable = typeof Proxy === 'function';\n", "export const HOOK_SETUP = 'devtools-plugin:setup';\nexport const HOOK_PLUGIN_SETTINGS_SET = 'plugin:settings:set';\n", "let supported;\nlet perf;\nexport function isPerformanceSupported() {\n var _a;\n if (supported !== undefined) {\n return supported;\n }\n if (typeof window !== 'undefined' && window.performance) {\n supported = true;\n perf = window.performance;\n }\n else if (typeof global !== 'undefined' && ((_a = global.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {\n supported = true;\n perf = global.perf_hooks.performance;\n }\n else {\n supported = false;\n }\n return supported;\n}\nexport function now() {\n return isPerformanceSupported() ? perf.now() : Date.now();\n}\n", "import { HOOK_PLUGIN_SETTINGS_SET } from './const.js';\nimport { now } from './time.js';\nexport class ApiProxy {\n constructor(plugin, hook) {\n this.target = null;\n this.targetQueue = [];\n this.onQueue = [];\n this.plugin = plugin;\n this.hook = hook;\n const defaultSettings = {};\n if (plugin.settings) {\n for (const id in plugin.settings) {\n const item = plugin.settings[id];\n defaultSettings[id] = item.defaultValue;\n }\n }\n const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;\n let currentSettings = Object.assign({}, defaultSettings);\n try {\n const raw = localStorage.getItem(localSettingsSaveId);\n const data = JSON.parse(raw);\n Object.assign(currentSettings, data);\n }\n catch (e) {\n // noop\n }\n this.fallbacks = {\n getSettings() {\n return currentSettings;\n },\n setSettings(value) {\n try {\n localStorage.setItem(localSettingsSaveId, JSON.stringify(value));\n }\n catch (e) {\n // noop\n }\n currentSettings = value;\n },\n now() {\n return now();\n },\n };\n if (hook) {\n hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {\n if (pluginId === this.plugin.id) {\n this.fallbacks.setSettings(value);\n }\n });\n }\n this.proxiedOn = new Proxy({}, {\n get: (_target, prop) => {\n if (this.target) {\n return this.target.on[prop];\n }\n else {\n return (...args) => {\n this.onQueue.push({\n method: prop,\n args,\n });\n };\n }\n },\n });\n this.proxiedTarget = new Proxy({}, {\n get: (_target, prop) => {\n if (this.target) {\n return this.target[prop];\n }\n else if (prop === 'on') {\n return this.proxiedOn;\n }\n else if (Object.keys(this.fallbacks).includes(prop)) {\n return (...args) => {\n this.targetQueue.push({\n method: prop,\n args,\n resolve: () => { },\n });\n return this.fallbacks[prop](...args);\n };\n }\n else {\n return (...args) => {\n return new Promise(resolve => {\n this.targetQueue.push({\n method: prop,\n args,\n resolve,\n });\n });\n };\n }\n },\n });\n }\n async setRealTarget(target) {\n this.target = target;\n for (const item of this.onQueue) {\n this.target.on[item.method](...item.args);\n }\n for (const item of this.targetQueue) {\n item.resolve(await this.target[item.method](...item.args));\n }\n }\n}\n", "import { getTarget, getDevtoolsGlobalHook, isProxyAvailable } from './env.js';\nimport { HOOK_SETUP } from './const.js';\nimport { ApiProxy } from './proxy.js';\nexport * from './api/index.js';\nexport * from './plugin.js';\nexport * from './time.js';\nexport function setupDevtoolsPlugin(pluginDescriptor, setupFn) {\n const descriptor = pluginDescriptor;\n const target = getTarget();\n const hook = getDevtoolsGlobalHook();\n const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;\n if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {\n hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);\n }\n else {\n const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;\n const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];\n list.push({\n pluginDescriptor: descriptor,\n setupFn,\n proxy,\n });\n if (proxy)\n setupFn(proxy.proxiedTarget);\n }\n}\n"], 5 | "mappings": ";AAAO,SAAS,wBAAwB;AACpC,SAAO,UAAU,EAAE;AACvB;AACO,SAAS,YAAY;AAExB,SAAQ,OAAO,cAAc,eAAe,OAAO,WAAW,cACxD,SACA,OAAO,WAAW,cACd,SACA,CAAC;AACf;AACO,IAAM,mBAAmB,OAAO,UAAU;;;ACX1C,IAAM,aAAa;AACnB,IAAM,2BAA2B;;;ACDxC,IAAI;AACJ,IAAI;AACG,SAAS,yBAAyB;AACrC,MAAI;AACJ,MAAI,cAAc,QAAW;AACzB,WAAO;AAAA,EACX;AACA,MAAI,OAAO,WAAW,eAAe,OAAO,aAAa;AACrD,gBAAY;AACZ,WAAO,OAAO;AAAA,EAClB,WACS,OAAO,WAAW,iBAAiB,KAAK,OAAO,gBAAgB,QAAQ,OAAO,SAAS,SAAS,GAAG,cAAc;AACtH,gBAAY;AACZ,WAAO,OAAO,WAAW;AAAA,EAC7B,OACK;AACD,gBAAY;AAAA,EAChB;AACA,SAAO;AACX;AACO,SAAS,MAAM;AAClB,SAAO,uBAAuB,IAAI,KAAK,IAAI,IAAI,KAAK,IAAI;AAC5D;;;ACpBO,IAAM,WAAN,MAAe;AAAA,EAClB,YAAY,QAAQ,MAAM;AACtB,SAAK,SAAS;AACd,SAAK,cAAc,CAAC;AACpB,SAAK,UAAU,CAAC;AAChB,SAAK,SAAS;AACd,SAAK,OAAO;AACZ,UAAM,kBAAkB,CAAC;AACzB,QAAI,OAAO,UAAU;AACjB,iBAAW,MAAM,OAAO,UAAU;AAC9B,cAAM,OAAO,OAAO,SAAS,EAAE;AAC/B,wBAAgB,EAAE,IAAI,KAAK;AAAA,MAC/B;AAAA,IACJ;AACA,UAAM,sBAAsB,mCAAmC,OAAO,EAAE;AACxE,QAAI,kBAAkB,OAAO,OAAO,CAAC,GAAG,eAAe;AACvD,QAAI;AACA,YAAM,MAAM,aAAa,QAAQ,mBAAmB;AACpD,YAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,aAAO,OAAO,iBAAiB,IAAI;AAAA,IACvC,SACO,GAAG;AAAA,IAEV;AACA,SAAK,YAAY;AAAA,MACb,cAAc;AACV,eAAO;AAAA,MACX;AAAA,MACA,YAAY,OAAO;AACf,YAAI;AACA,uBAAa,QAAQ,qBAAqB,KAAK,UAAU,KAAK,CAAC;AAAA,QACnE,SACO,GAAG;AAAA,QAEV;AACA,0BAAkB;AAAA,MACtB;AAAA,MACA,MAAM;AACF,eAAO,IAAI;AAAA,MACf;AAAA,IACJ;AACA,QAAI,MAAM;AACN,WAAK,GAAG,0BAA0B,CAAC,UAAU,UAAU;AACnD,YAAI,aAAa,KAAK,OAAO,IAAI;AAC7B,eAAK,UAAU,YAAY,KAAK;AAAA,QACpC;AAAA,MACJ,CAAC;AAAA,IACL;AACA,SAAK,YAAY,IAAI,MAAM,CAAC,GAAG;AAAA,MAC3B,KAAK,CAAC,SAAS,SAAS;AACpB,YAAI,KAAK,QAAQ;AACb,iBAAO,KAAK,OAAO,GAAG,IAAI;AAAA,QAC9B,OACK;AACD,iBAAO,IAAI,SAAS;AAChB,iBAAK,QAAQ,KAAK;AAAA,cACd,QAAQ;AAAA,cACR;AAAA,YACJ,CAAC;AAAA,UACL;AAAA,QACJ;AAAA,MACJ;AAAA,IACJ,CAAC;AACD,SAAK,gBAAgB,IAAI,MAAM,CAAC,GAAG;AAAA,MAC/B,KAAK,CAAC,SAAS,SAAS;AACpB,YAAI,KAAK,QAAQ;AACb,iBAAO,KAAK,OAAO,IAAI;AAAA,QAC3B,WACS,SAAS,MAAM;AACpB,iBAAO,KAAK;AAAA,QAChB,WACS,OAAO,KAAK,KAAK,SAAS,EAAE,SAAS,IAAI,GAAG;AACjD,iBAAO,IAAI,SAAS;AAChB,iBAAK,YAAY,KAAK;AAAA,cAClB,QAAQ;AAAA,cACR;AAAA,cACA,SAAS,MAAM;AAAA,cAAE;AAAA,YACrB,CAAC;AACD,mBAAO,KAAK,UAAU,IAAI,EAAE,GAAG,IAAI;AAAA,UACvC;AAAA,QACJ,OACK;AACD,iBAAO,IAAI,SAAS;AAChB,mBAAO,IAAI,QAAQ,aAAW;AAC1B,mBAAK,YAAY,KAAK;AAAA,gBAClB,QAAQ;AAAA,gBACR;AAAA,gBACA;AAAA,cACJ,CAAC;AAAA,YACL,CAAC;AAAA,UACL;AAAA,QACJ;AAAA,MACJ;AAAA,IACJ,CAAC;AAAA,EACL;AAAA,EACA,MAAM,cAAc,QAAQ;AACxB,SAAK,SAAS;AACd,eAAW,QAAQ,KAAK,SAAS;AAC7B,WAAK,OAAO,GAAG,KAAK,MAAM,EAAE,GAAG,KAAK,IAAI;AAAA,IAC5C;AACA,eAAW,QAAQ,KAAK,aAAa;AACjC,WAAK,QAAQ,MAAM,KAAK,OAAO,KAAK,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;AAAA,IAC7D;AAAA,EACJ;AACJ;;;ACpGO,SAAS,oBAAoB,kBAAkB,SAAS;AAC3D,QAAM,aAAa;AACnB,QAAM,SAAS,UAAU;AACzB,QAAM,OAAO,sBAAsB;AACnC,QAAM,cAAc,oBAAoB,WAAW;AACnD,MAAI,SAAS,OAAO,yCAAyC,CAAC,cAAc;AACxE,SAAK,KAAK,YAAY,kBAAkB,OAAO;AAAA,EACnD,OACK;AACD,UAAM,QAAQ,cAAc,IAAI,SAAS,YAAY,IAAI,IAAI;AAC7D,UAAM,OAAO,OAAO,2BAA2B,OAAO,4BAA4B,CAAC;AACnF,SAAK,KAAK;AAAA,MACN,kBAAkB;AAAA,MAClB;AAAA,MACA;AAAA,IACJ,CAAC;AACD,QAAI;AACA,cAAQ,MAAM,aAAa;AAAA,EACnC;AACJ;", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /test/useFormErrors.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basicSchema, 10 | basicWithSimilarNamesSchema, 11 | objectSchema, 12 | sleep, 13 | } from './testUtils' 14 | 15 | describe('errors', () => { 16 | it('should not have any errors when all fields are valid', async () => { 17 | const form = useForm({ 18 | schema: basicSchema, 19 | onSubmit: (data) => { 20 | return data 21 | }, 22 | }) 23 | 24 | const name = form.register('name', 'John') 25 | 26 | await sleep(0) 27 | 28 | expect(form.errors.value).toEqual([]) 29 | expect(name.errors.value).toEqual([]) 30 | }) 31 | 32 | it('should have errors when a field is invalid', async () => { 33 | const form = useForm({ 34 | schema: basicSchema, 35 | onSubmit: (data) => { 36 | return data 37 | }, 38 | }) 39 | 40 | const name = form.register('name', 'Jon') 41 | 42 | await sleep(0) 43 | 44 | expect(form.errors.value).toEqual([ 45 | { 46 | message: 'String must contain at least 4 character(s)', 47 | path: 'name', 48 | }, 49 | ]) 50 | 51 | expect(name.errors.value).toEqual([ 52 | { 53 | message: 'String must contain at least 4 character(s)', 54 | path: null, 55 | }, 56 | ]) 57 | }) 58 | 59 | it('should have errors when a nested field is invalid', async () => { 60 | const form = useForm({ 61 | schema: objectSchema, 62 | onSubmit: (data) => { 63 | return data 64 | }, 65 | 66 | }) 67 | 68 | const a = form.register('a') 69 | 70 | a.register('b') 71 | 72 | await sleep(0) 73 | 74 | a.errors.value.find((error) => error.path === null) 75 | expect(a.errors.value).toBeDefined() 76 | expect(form.errors.value).toBeDefined() 77 | }) 78 | 79 | it('should set errors with `addErrors`', () => { 80 | const form = useForm({ 81 | schema: basicSchema, 82 | onSubmit: (data) => { 83 | return data 84 | }, 85 | }) 86 | 87 | form.addErrors([ 88 | { 89 | message: 'Invalid name', 90 | path: 'name', 91 | }, 92 | ]) 93 | 94 | expect(form.errors.value).toEqual([ 95 | { 96 | message: 'Invalid name', 97 | path: 'name', 98 | }, 99 | ]) 100 | }) 101 | 102 | it('should set nested errors with `addErrors` while existing errors remain', () => { 103 | const form = useForm({ 104 | schema: objectSchema, 105 | onSubmit: (data) => { 106 | return data 107 | }, 108 | }) 109 | 110 | form.addErrors([ 111 | { 112 | message: 'Invalid name', 113 | path: 'a.b', 114 | }, 115 | ]) 116 | 117 | expect(form.errors.value).toEqual([ 118 | { 119 | message: 'Invalid name', 120 | path: 'a.b', 121 | }, 122 | ]) 123 | 124 | form.addErrors([ 125 | { 126 | message: 'Invalid name', 127 | path: 'a.bObj.c', 128 | }, 129 | ]) 130 | 131 | expect(form.errors.value).toEqual([ 132 | { 133 | message: 'Invalid name', 134 | path: 'a.b', 135 | }, 136 | { 137 | message: 'Invalid name', 138 | path: 'a.bObj.c', 139 | }, 140 | ]) 141 | }) 142 | 143 | it('form should have raw errors', async () => { 144 | const form = useForm({ 145 | schema: basicSchema, 146 | onSubmit: (data) => { 147 | return data 148 | }, 149 | }) 150 | 151 | form.addErrors([ 152 | { 153 | message: 'Invalid name', 154 | path: 'name', 155 | }, 156 | ]) 157 | 158 | await sleep(0) 159 | 160 | expect(form.rawErrors.value).toEqual( 161 | [ 162 | { 163 | code: 'invalid_type', 164 | expected: 'string', 165 | message: 'Required', 166 | received: 'undefined', 167 | path: [ 168 | 'name', 169 | ], 170 | }, 171 | ], 172 | ) 173 | }) 174 | 175 | it('form should have no raw errors when all fields are valid', async () => { 176 | const form = useForm({ 177 | initialState: { name: 'I am a name' }, 178 | schema: basicSchema, 179 | onSubmit: (data) => { 180 | return data 181 | }, 182 | }) 183 | 184 | await sleep(0) 185 | 186 | expect(form.rawErrors.value).toEqual([]) 187 | }) 188 | 189 | it('form should have nested raw errors', async () => { 190 | const form = useForm({ 191 | schema: objectSchema, 192 | onSubmit: (data) => { 193 | return data 194 | }, 195 | }) 196 | 197 | await sleep(0) 198 | 199 | expect(form.rawErrors.value).toEqual([ 200 | { 201 | code: 'invalid_type', 202 | expected: 'object', 203 | message: 'Required', 204 | received: 'undefined', 205 | path: [ 206 | 'a', 207 | ], 208 | }, 209 | ]) 210 | 211 | form.addErrors([ 212 | { 213 | message: 'Invalid name', 214 | path: 'a.b', 215 | }, 216 | ]) 217 | 218 | expect(form.rawErrors.value).toEqual([ 219 | { 220 | code: 'invalid_type', 221 | expected: 'object', 222 | message: 'Required', 223 | received: 'undefined', 224 | path: [ 225 | 'a', 226 | ], 227 | }, 228 | { 229 | message: 'Invalid name', 230 | path: [ 231 | 'a', 232 | 'b', 233 | ], 234 | }, 235 | ]) 236 | }) 237 | 238 | it('field should have raw errors', async () => { 239 | const form = useForm({ 240 | initialState: { name: null }, 241 | schema: basicSchema, 242 | onSubmit: (data) => { 243 | return data 244 | }, 245 | }) 246 | 247 | const name = form.register('name') 248 | 249 | await sleep(0) 250 | 251 | expect(name.rawErrors.value).toEqual([ 252 | { 253 | code: 'invalid_type', 254 | expected: 'string', 255 | message: 'Expected string, received null', 256 | received: 'null', 257 | path: [], 258 | }, 259 | ]) 260 | }) 261 | 262 | it('field should have nested raw errors', async () => { 263 | const form = useForm({ 264 | initialState: { 265 | a: { 266 | b: null, 267 | bObj: { c: null }, 268 | }, 269 | }, 270 | schema: objectSchema, 271 | onSubmit: (data) => { 272 | return data 273 | }, 274 | }) 275 | 276 | const a = form.register('a') 277 | 278 | await sleep(0) 279 | 280 | expect(a.rawErrors.value).toEqual([ 281 | { 282 | code: 'invalid_type', 283 | expected: 'string', 284 | message: 'Expected string, received null', 285 | received: 'null', 286 | path: [ 287 | 'b', 288 | ], 289 | }, 290 | { 291 | code: 'invalid_type', 292 | expected: 'string', 293 | message: 'Expected string, received null', 294 | received: 'null', 295 | path: [ 296 | 'bObj', 297 | 'c', 298 | ], 299 | }, 300 | ]) 301 | 302 | form.addErrors([ 303 | { 304 | message: 'Invalid name', 305 | path: 'a.b', 306 | }, 307 | ]) 308 | 309 | expect(a.rawErrors.value).toEqual([ 310 | { 311 | code: 'invalid_type', 312 | expected: 'string', 313 | message: 'Expected string, received null', 314 | received: 'null', 315 | path: [ 316 | 'b', 317 | ], 318 | }, 319 | { 320 | code: 'invalid_type', 321 | expected: 'string', 322 | message: 'Expected string, received null', 323 | received: 'null', 324 | path: [ 325 | 'bObj', 326 | 'c', 327 | ], 328 | }, 329 | { 330 | message: 'Invalid name', 331 | path: [ 332 | 'b', 333 | ], 334 | }, 335 | ]) 336 | }) 337 | 338 | it('fields with a similar name should not have shared errors', async () => { 339 | const form = useForm({ 340 | schema: basicWithSimilarNamesSchema, 341 | onSubmit: (data) => { 342 | return data 343 | }, 344 | }) 345 | 346 | const nameFirst = form.register('nameFirst', 'a') 347 | const nameSecond = form.register('nameSecond', 'a') 348 | const name = form.register('name', 'a') 349 | 350 | await sleep(0) 351 | 352 | expect(nameFirst.errors.value).toEqual([ 353 | { 354 | message: 'String must contain at least 4 character(s)', 355 | path: null, 356 | }, 357 | ]) 358 | expect(nameSecond.errors.value).toEqual([ 359 | { 360 | message: 'String must contain at least 4 character(s)', 361 | path: null, 362 | }, 363 | ]) 364 | expect(name.errors.value).toEqual([]) 365 | }) 366 | 367 | it('fields should share errors with its parents', async () => { 368 | const form = useForm({ 369 | schema: objectSchema, 370 | onSubmit: (data) => { 371 | return data 372 | }, 373 | }) 374 | 375 | const aBObjC = form.register('a.bObj.c') 376 | const aBObj = form.register('a.bObj') 377 | const a = form.register('a') 378 | const ab = form.register('a.b') 379 | 380 | await sleep(0) 381 | 382 | expect(aBObjC.errors.value).toEqual([ 383 | { 384 | message: 'Expected string, received null', 385 | path: null, 386 | }, 387 | ]) 388 | expect(aBObj.errors.value).toEqual([ 389 | { 390 | message: 'Expected string, received null', 391 | path: 'c', 392 | }, 393 | ]) 394 | expect(ab.errors.value).toEqual([ 395 | { 396 | message: 'Expected string, received null', 397 | path: null, 398 | }, 399 | ]) 400 | expect(a.errors.value).toEqual([ 401 | { 402 | message: 'Expected string, received null', 403 | path: 'b', 404 | }, 405 | { 406 | message: 'Expected string, received null', 407 | path: 'bObj.c', 408 | }, 409 | ]) 410 | }) 411 | }) 412 | -------------------------------------------------------------------------------- /src/devtools/devtools.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/prefer-global/process */ 2 | import type { 3 | App, 4 | CustomInspectorNode, 5 | DevtoolsPluginApi, 6 | } from '@vue/devtools-api' 7 | import { setupDevtoolsPlugin } from '@vue/devtools-api' 8 | import type { UnwrapRef } from 'vue' 9 | import { 10 | getCurrentInstance, 11 | nextTick, 12 | onUnmounted, 13 | ref, 14 | watch, 15 | } from 'vue' 16 | 17 | import type { 18 | Field, 19 | Form, 20 | } from '../types' 21 | import type { 22 | EncodedNode, 23 | FieldNode, 24 | FormNode, 25 | ObjectWithPossiblyFieldRecursive, 26 | } from '../types/devtools.type' 27 | import { throttle } from '../utils' 28 | import { 29 | buildFieldState, 30 | buildFormState, 31 | } from './devtoolsBuilder' 32 | 33 | let API: DevtoolsPluginApi> | undefined 34 | const INSPECTOR_ID = 'formango-inspector' 35 | const DEVTOOLS_FORMS = ref }>>({}) 37 | const DEVTOOLS_FIELDS = ref & { __ID__?: string } }>>({}) 39 | 40 | const COLORS = { 41 | black: 0x00_00_00, 42 | blue: 0x03_53_97, 43 | error: 0xBD_4B_4B, 44 | gray: 0xBB_BF_CA, 45 | orange: 0xF5_A9_62, 46 | purple: 0xB9_80_F0, 47 | success: 0x06_D7_7B, 48 | unknown: 0x54_43_6B, 49 | white: 0xFF_FF_FF, 50 | } 51 | 52 | let IS_INSTALLED = false 53 | 54 | function mapFieldsToObject(fields: UnwrapRef[]>): ObjectWithPossiblyFieldRecursive { 55 | const obj = {} 56 | 57 | for (const field of fields) { 58 | if (!field._path) { 59 | continue 60 | } 61 | const path = field._path 62 | const pathArray = path?.split('.') 63 | 64 | if (!pathArray) { 65 | continue 66 | } 67 | const lastKey = pathArray.pop() as keyof typeof lastObj 68 | const lastObj = pathArray.reduce((obj, key) => obj[key] = obj[key] || {}, obj) 69 | 70 | if (!lastObj[lastKey]) { 71 | lastObj[lastKey] = {} 72 | } 73 | 74 | lastObj[lastKey].__FIELD__ = field 75 | } 76 | 77 | return obj 78 | } 79 | 80 | // recursively map the mappedObjects to a CustomInspectorNode 81 | let nonFieldsCounter = 0 82 | 83 | function mapObjectToCustomInspectorNode(obj: ObjectWithPossiblyFieldRecursive): CustomInspectorNode[] { 84 | return Object.keys(obj).map((key) => { 85 | const value = obj[key] 86 | 87 | if (value.__FIELD__) { 88 | const field = value.__FIELD__ 89 | const hasError = field.errors && Object.values(field.errors).length > 0 90 | const validTag = { 91 | backgroundColor: hasError ? COLORS.error : COLORS.success, 92 | label: hasError ? 'Invalid' : 'Valid', 93 | textColor: COLORS.white, 94 | } 95 | 96 | const tags = [] 97 | 98 | if (hasError) { 99 | tags.push(validTag) 100 | } 101 | 102 | delete value.__FIELD__ 103 | 104 | return { 105 | id: field.__ID__, 106 | label: key, 107 | tags, 108 | children: mapObjectToCustomInspectorNode(value), 109 | } 110 | } 111 | else { 112 | nonFieldsCounter++ 113 | 114 | return { 115 | id: `non-field-${nonFieldsCounter}`, 116 | label: key, 117 | tags: [ 118 | { 119 | backgroundColor: COLORS.orange, 120 | label: 'Not registered', 121 | textColor: COLORS.white, 122 | }, 123 | ], 124 | children: mapObjectToCustomInspectorNode(value), 125 | } 126 | } 127 | }) 128 | } 129 | 130 | function calculateNodes(): CustomInspectorNode[] { 131 | nonFieldsCounter = 0 132 | 133 | return Object.keys(DEVTOOLS_FORMS.value).map((formId: string) => { 134 | const form = DEVTOOLS_FORMS.value[formId] 135 | const actualForm = form.form as unknown as UnwrapRef> 136 | 137 | const foundFieldKeys = Object.keys(DEVTOOLS_FIELDS.value).filter((key) => { 138 | const field = DEVTOOLS_FIELDS.value[key] 139 | 140 | return form.form._id === field.formId 141 | }) 142 | 143 | const allFormFields = foundFieldKeys.map((key) => { 144 | const field = DEVTOOLS_FIELDS.value[key] 145 | 146 | field.field.__ID__ = key 147 | 148 | return field.field 149 | }) as unknown as UnwrapRef>[] 150 | 151 | const mappedAsObject = mapFieldsToObject(allFormFields) 152 | const formChildren = mapObjectToCustomInspectorNode(mappedAsObject) 153 | 154 | const validTag = { 155 | backgroundColor: actualForm.isValid ? COLORS.success : COLORS.error, 156 | label: actualForm.isValid ? 'Valid' : 'Invalid', 157 | textColor: COLORS.white, 158 | } 159 | 160 | return { 161 | id: formId, 162 | label: `${form.name}`, 163 | tags: [ 164 | validTag, 165 | ], 166 | children: formChildren, 167 | } 168 | }) 169 | } 170 | 171 | export const refreshInspector = throttle(() => { 172 | setTimeout(async () => { 173 | await nextTick() 174 | 175 | API?.sendInspectorState(INSPECTOR_ID) 176 | API?.sendInspectorTree(INSPECTOR_ID) 177 | }, 100) 178 | }, 100) 179 | 180 | const isDevMode = process.env.NODE_ENV === 'development' 181 | 182 | function installDevtoolsPlugin(app: App) { 183 | if (!isDevMode) { 184 | return 185 | } 186 | 187 | setupDevtoolsPlugin( 188 | { 189 | id: 'formango-devtools-plugin', 190 | app, 191 | homepage: 'https://github.com/wisemen-digital/vue-formango', 192 | label: 'Formango Plugin', 193 | logo: 'https://wisemen-digital.github.io/vue-formango/assets/mango_no_shadow.svg', 194 | packageName: 'formango', 195 | }, 196 | setupApiHooks, 197 | ) 198 | } 199 | 200 | function setupApiHooks(api: DevtoolsPluginApi>) { 201 | API = api 202 | 203 | api.addInspector({ 204 | id: INSPECTOR_ID, 205 | icon: 'rule', 206 | label: 'formango', 207 | noSelectionText: 'Select a form node to inspect', 208 | }) 209 | 210 | api.on.getInspectorTree((payload) => { 211 | if (payload.inspectorId !== INSPECTOR_ID) { 212 | return 213 | } 214 | 215 | try { 216 | const calculatedNodes = calculateNodes() 217 | 218 | payload.rootNodes = calculatedNodes 219 | } 220 | catch (error) { 221 | console.error('Error with calculating devtools nodes') 222 | console.error(error) 223 | } 224 | }) 225 | 226 | api.on.getInspectorState((payload) => { 227 | if (payload.inspectorId !== INSPECTOR_ID) { 228 | return 229 | } 230 | 231 | const decodedNode = decodeNodeId(payload.nodeId) 232 | 233 | if (decodedNode?.type === 'form' && decodedNode?.form) { 234 | payload.state = buildFormState(decodedNode.form as unknown as UnwrapRef>) 235 | } 236 | else if (decodedNode?.type === 'field' && decodedNode?.field?.field) { 237 | payload.state = buildFieldState(decodedNode?.field.field as unknown as UnwrapRef>) 238 | } 239 | }) 240 | } 241 | 242 | function installPlugin() { 243 | if (!isDevMode) { 244 | return 245 | } 246 | 247 | const vm = getCurrentInstance() 248 | 249 | if (!IS_INSTALLED) { 250 | IS_INSTALLED = true 251 | 252 | const app = vm?.appContext.app 253 | 254 | if (!app) { 255 | return 256 | } 257 | 258 | installDevtoolsPlugin(app) 259 | } 260 | } 261 | 262 | export function registerFormWithDevTools(form: Form, name?: string) { 263 | if (!isDevMode) { 264 | return 265 | } 266 | 267 | installPlugin() 268 | 269 | if (!form?._id) { 270 | return 271 | } 272 | 273 | // get component name from the instance 274 | const componentName = getCurrentInstance()?.type.__name 275 | 276 | const encodedForm = encodeNodeId({ 277 | id: form._id, 278 | name: name ?? 'Unknown form', 279 | type: 'form', 280 | }) 281 | 282 | DEVTOOLS_FORMS.value[encodedForm] = { 283 | name: componentName ?? 'Unknown form', 284 | form, 285 | } 286 | onUnmounted(() => { 287 | const formFields = Object.keys(DEVTOOLS_FIELDS.value).filter((fieldId: string) => { 288 | const field = DEVTOOLS_FIELDS.value[fieldId] 289 | 290 | return field.formId === form?._id 291 | }) 292 | 293 | delete DEVTOOLS_FORMS.value[encodedForm] 294 | // eslint-disable-next-line unicorn/no-array-for-each 295 | formFields.forEach((formFieldId: string) => { 296 | delete DEVTOOLS_FIELDS.value[formFieldId] 297 | }) 298 | }) 299 | } 300 | 301 | export function registerFieldWithDevTools(formId: string, field: Field) { 302 | if (!isDevMode) { 303 | return 304 | } 305 | 306 | installPlugin() 307 | 308 | const encodedField = encodeNodeId({ 309 | id: field._id, 310 | type: 'field', 311 | }) 312 | 313 | DEVTOOLS_FIELDS.value[encodedField] = { 314 | formId, 315 | field, 316 | } 317 | } 318 | 319 | export function unregisterFieldWithDevTools(fieldId: string) { 320 | if (!isDevMode) { 321 | return 322 | } 323 | 324 | const encodedField = encodeNodeId({ 325 | id: fieldId, 326 | type: 'field', 327 | }) 328 | 329 | delete DEVTOOLS_FIELDS.value[encodedField] 330 | } 331 | 332 | function encodeNodeId(node: EncodedNode): string { 333 | return btoa(encodeURIComponent(JSON.stringify(node))) 334 | } 335 | 336 | function decodeNodeId(nodeId: string): FieldNode | FormNode | null { 337 | try { 338 | const decodedNode = JSON.parse(decodeURIComponent(atob(nodeId))) as EncodedNode 339 | 340 | if (!decodedNode) { 341 | throw new Error('Invalid node id') 342 | } 343 | if (decodedNode.type === 'form' && DEVTOOLS_FORMS.value[nodeId]) { 344 | return { 345 | name: decodedNode.name, 346 | form: DEVTOOLS_FORMS.value[nodeId].form, 347 | type: 'form', 348 | } 349 | } 350 | else { 351 | return { 352 | field: DEVTOOLS_FIELDS.value[nodeId], 353 | type: 'field', 354 | } 355 | } 356 | } 357 | catch { 358 | // console.error(`Devtools: [vee-validate] Failed to parse node id ${nodeId}`); 359 | } 360 | 361 | return null 362 | } 363 | 364 | if (isDevMode) { 365 | watch([ 366 | DEVTOOLS_FORMS.value, 367 | DEVTOOLS_FIELDS.value, 368 | ], refreshInspector, { deep: true }) 369 | } 370 | -------------------------------------------------------------------------------- /test/useFormRegister.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | } from 'vitest' 6 | 7 | import { useForm } from '../src/lib/useForm' 8 | import { 9 | basic2DArraySchema, 10 | basicArraySchema, 11 | basicSchema, 12 | fieldWithArraySchema, 13 | objectArraySchema, 14 | objectSchema, 15 | twoDimensionalArraySchema, 16 | } from './testUtils' 17 | 18 | describe('register a field or fieldArray', () => { 19 | it('should register a field', () => { 20 | const form = useForm({ 21 | schema: basicSchema, 22 | onSubmit: (data) => { 23 | return data 24 | }, 25 | }) 26 | 27 | const name = form.register('name') 28 | 29 | expect(name.modelValue.value).toBeNull() 30 | 31 | expect(form.state.value).toEqual({ name: null }) 32 | }) 33 | 34 | it('should register a field which has already been registered', () => { 35 | const form = useForm({ 36 | schema: basicSchema, 37 | onSubmit: (data) => { 38 | return data 39 | }, 40 | }) 41 | 42 | const name = form.register('name') 43 | 44 | name.setValue('John') 45 | 46 | const name2 = form.register('name') 47 | 48 | expect(name.modelValue.value).toBe('John') 49 | expect(name2.modelValue.value).toBe('John') 50 | 51 | expect(form.state.value).toEqual({ name: 'John' }) 52 | }) 53 | 54 | it('should register a field with a default value', () => { 55 | const form = useForm({ 56 | schema: basicSchema, 57 | onSubmit: (data) => { 58 | return data 59 | }, 60 | 61 | }) 62 | 63 | const name = form.register('name', 'John') 64 | 65 | expect(name.modelValue.value).toBe('John') 66 | 67 | expect(form.state.value).toEqual({ name: 'John' }) 68 | }) 69 | 70 | it('should register a field with a default value from the initial state', () => { 71 | const form = useForm({ 72 | initialState: { name: 'John' }, 73 | schema: basicSchema, 74 | onSubmit: (data) => { 75 | return data 76 | }, 77 | }) 78 | 79 | const name = form.register('name') 80 | 81 | expect(name.modelValue.value).toBe('John') 82 | 83 | expect(form.state.value).toEqual({ name: 'John' }) 84 | }) 85 | 86 | it('should register a nested field', () => { 87 | const form = useForm({ 88 | schema: objectSchema, 89 | onSubmit: (data) => { 90 | return data 91 | }, 92 | }) 93 | 94 | form.register('a') 95 | 96 | const b = form.register('a.b') 97 | 98 | expect(b.modelValue.value).toBeNull() 99 | 100 | expect(form.state.value).toEqual({ a: { b: null } }) 101 | }) 102 | 103 | it('should register a nested field with a default value', () => { 104 | const form = useForm({ 105 | schema: objectSchema, 106 | onSubmit: (data) => { 107 | return data 108 | }, 109 | }) 110 | 111 | form.register('a') 112 | 113 | const b = form.register('a.b', 'John') 114 | 115 | expect(b.modelValue.value).toBe('John') 116 | 117 | expect(form.state.value).toEqual({ a: { b: 'John' } }) 118 | }) 119 | 120 | it('should register a nested field without its parent being registed', () => { 121 | const form = useForm({ 122 | schema: objectSchema, 123 | onSubmit: (data) => { 124 | return data 125 | }, 126 | }) 127 | 128 | const b = form.register('a.b') 129 | 130 | expect(b.modelValue.value).toBeNull() 131 | 132 | expect(form.state.value).toEqual({ a: { b: null } }) 133 | }) 134 | 135 | it('should register an array field', () => { 136 | const form = useForm({ 137 | schema: basicArraySchema, 138 | onSubmit: (data) => { 139 | return data 140 | }, 141 | }) 142 | 143 | const array = form.registerArray('array') 144 | 145 | expect(array.modelValue.value).toEqual([]) 146 | 147 | expect(form.state.value).toEqual({ array: [] }) 148 | }) 149 | 150 | it('should register an array field with a default value', () => { 151 | const form = useForm({ 152 | schema: basicArraySchema, 153 | onSubmit: (data) => { 154 | return data 155 | }, 156 | 157 | }) 158 | 159 | const array = form.registerArray('array', [ 160 | 'John', 161 | ]) 162 | 163 | expect(array.modelValue.value).toEqual([ 164 | 'John', 165 | ]) 166 | 167 | expect(form.state.value).toEqual({ 168 | array: [ 169 | 'John', 170 | ], 171 | }) 172 | }) 173 | 174 | it('should register an array field with an initial state', () => { 175 | const form = useForm({ 176 | initialState: { 177 | array: [ 178 | 'John', 179 | ], 180 | }, 181 | schema: basicArraySchema, 182 | onSubmit: (data) => { 183 | return data 184 | }, 185 | }) 186 | 187 | const array = form.registerArray('array') 188 | 189 | expect(array.modelValue.value).toEqual([ 190 | 'John', 191 | ]) 192 | 193 | expect(form.state.value).toEqual({ 194 | array: [ 195 | 'John', 196 | ], 197 | }) 198 | }) 199 | 200 | it('should register a nested array field', () => { 201 | const form = useForm({ 202 | schema: objectArraySchema, 203 | onSubmit: (data) => { 204 | return data 205 | }, 206 | }) 207 | 208 | const array0Name = form.register('array.0.name') 209 | 210 | expect(array0Name.modelValue.value).toBeNull() 211 | 212 | expect(form.state.value).toEqual({ 213 | array: [ 214 | { name: null }, 215 | ], 216 | }) 217 | }) 218 | 219 | it('should register a nested array field with a default value', () => { 220 | const form = useForm({ 221 | schema: twoDimensionalArraySchema, 222 | onSubmit: (data) => { 223 | return data 224 | }, 225 | }) 226 | 227 | const name = form.register('array.0.0.name', 'John') 228 | 229 | expect(name.modelValue.value).toBe('John') 230 | 231 | expect(form.state.value).toEqual({ 232 | array: [ 233 | [ 234 | { name: 'John' }, 235 | ], 236 | ], 237 | }) 238 | }) 239 | 240 | it('should register a field as a fieldArray', () => { 241 | const form = useForm({ 242 | schema: basicArraySchema, 243 | onSubmit: (data) => { 244 | return data 245 | }, 246 | }) 247 | 248 | const arrayAsField = form.register('array', [ 249 | 'John', 250 | ]) 251 | const arrayAsArray = form.registerArray('array') 252 | 253 | expect(form.state.value).toEqual({ 254 | array: [ 255 | 'John', 256 | ], 257 | }) 258 | 259 | expect(arrayAsField.modelValue.value).toEqual([ 260 | 'John', 261 | ]) 262 | expect(arrayAsArray.modelValue.value).toEqual([ 263 | 'John', 264 | ]) 265 | 266 | arrayAsField.setValue([ 267 | 'Doe', 268 | ]) 269 | 270 | expect(form.state.value).toEqual({ 271 | array: [ 272 | 'Doe', 273 | ], 274 | }) 275 | 276 | expect(arrayAsField.modelValue.value).toEqual([ 277 | 'Doe', 278 | ]) 279 | expect(arrayAsArray.modelValue.value).toEqual([ 280 | 'Doe', 281 | ]) 282 | 283 | arrayAsArray.append('John') 284 | 285 | expect(form.state.value).toEqual({ 286 | array: [ 287 | 'Doe', 288 | 'John', 289 | ], 290 | }) 291 | 292 | expect(arrayAsField.modelValue.value).toEqual([ 293 | 'Doe', 294 | 'John', 295 | ]) 296 | expect(arrayAsArray.modelValue.value).toEqual([ 297 | 'Doe', 298 | 'John', 299 | ]) 300 | }) 301 | 302 | it('should register a fieldArray as a field', () => { 303 | const form = useForm({ 304 | schema: basicArraySchema, 305 | onSubmit: (data) => { 306 | return data 307 | }, 308 | }) 309 | 310 | const arrayAsArray = form.registerArray('array', [ 311 | 'John', 312 | ]) 313 | const arrayAsField = form.register('array') 314 | 315 | expect(form.state.value).toEqual({ 316 | array: [ 317 | 'John', 318 | ], 319 | }) 320 | 321 | expect(arrayAsField.modelValue.value).toEqual([ 322 | 'John', 323 | ]) 324 | expect(arrayAsArray.modelValue.value).toEqual([ 325 | 'John', 326 | ]) 327 | 328 | arrayAsField.setValue([ 329 | 'Doe', 330 | ]) 331 | 332 | expect(form.state.value).toEqual({ 333 | array: [ 334 | 'Doe', 335 | ], 336 | }) 337 | 338 | expect(arrayAsField.modelValue.value).toEqual([ 339 | 'Doe', 340 | ]) 341 | expect(arrayAsArray.modelValue.value).toEqual([ 342 | 'Doe', 343 | ]) 344 | 345 | arrayAsArray.append('John') 346 | 347 | expect(form.state.value).toEqual({ 348 | array: [ 349 | 'Doe', 350 | 'John', 351 | ], 352 | }) 353 | 354 | expect(arrayAsField.modelValue.value).toEqual([ 355 | 'Doe', 356 | 'John', 357 | ]) 358 | expect(arrayAsArray.modelValue.value).toEqual([ 359 | 'Doe', 360 | 'John', 361 | ]) 362 | }) 363 | 364 | it('should register a 2D array field with a default value', () => { 365 | const form = useForm({ 366 | schema: basic2DArraySchema, 367 | onSubmit: (data) => { 368 | return data 369 | }, 370 | 371 | }) 372 | 373 | const array = form.registerArray('array', [ 374 | [], 375 | ]) 376 | 377 | expect(array.modelValue.value).toEqual([ 378 | [], 379 | ]) 380 | 381 | expect(form.state.value).toEqual({ 382 | array: [ 383 | [], 384 | ], 385 | }) 386 | 387 | array.register('0', [ 388 | 'John', 389 | ]) 390 | 391 | expect(array.modelValue.value).toEqual([ 392 | [ 393 | 'John', 394 | ], 395 | ]) 396 | }) 397 | 398 | it('should be able to register a field that registers an array field', () => { 399 | const form = useForm({ 400 | schema: fieldWithArraySchema, 401 | onSubmit: (data) => { 402 | return data 403 | }, 404 | }) 405 | 406 | const field = form.register('field') 407 | const array = field.registerArray('array', []) 408 | 409 | expect(array.modelValue.value).toEqual([]) 410 | expect(field.modelValue.value).toEqual({ array: [] }) 411 | 412 | array.append('John') 413 | 414 | expect(array.modelValue.value).toEqual([ 415 | 'John', 416 | ]) 417 | expect(field.modelValue.value).toEqual({ 418 | array: [ 419 | 'John', 420 | ], 421 | }) 422 | }) 423 | 424 | it('should be able to register an array field that registers an array field', () => { 425 | const form = useForm({ 426 | schema: twoDimensionalArraySchema, 427 | onSubmit: (data) => { 428 | return data 429 | }, 430 | }) 431 | 432 | const array = form.registerArray('array', []) 433 | const array2 = array.registerArray('0', [ 434 | { name: 'Wouter' }, 435 | ]) 436 | 437 | expect(array.modelValue.value).toEqual([ 438 | [ 439 | { name: 'Wouter' }, 440 | ], 441 | ]) 442 | expect(array2.modelValue.value).toEqual([ 443 | { name: 'Wouter' }, 444 | ]) 445 | 446 | array.append([ 447 | { name: 'Robbe' }, 448 | ]) 449 | 450 | expect(array.modelValue.value).toEqual([ 451 | [ 452 | { name: 'Wouter' }, 453 | ], 454 | [ 455 | { name: 'Robbe' }, 456 | ], 457 | 458 | ]) 459 | }) 460 | }) 461 | --------------------------------------------------------------------------------
99 | {{ label }} 100 |
51 | {{ errors._errors[0] }} 52 |