├── .npmrc ├── bun.lockb ├── playground ├── tsconfig.json ├── server │ ├── tsconfig.json │ ├── validators │ │ └── index.ts │ └── api │ │ └── endpoint.post.ts ├── nuxt.config.ts ├── package.json └── app.vue ├── .vscode └── settings.json ├── src ├── runtime │ ├── server │ │ ├── tsconfig.json │ │ └── utils │ │ │ ├── validation.ts │ │ │ └── form-data.ts │ └── composables │ │ └── useForm.ts └── module.ts ├── test ├── fixtures │ └── basic │ │ ├── package.json │ │ ├── app.vue │ │ └── nuxt.config.ts └── basic.test.ts ├── tsconfig.json ├── .editorconfig ├── CHANGELOG.md ├── eslint.config.mjs ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lschvn/nuxt-form/HEAD/bun.lockb -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.experimental.useFlatConfig": true 3 | } 4 | -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/runtime/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.nuxt/tsconfig.server.json", 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "basic", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/basic/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | "exclude": [ 4 | "dist", 5 | "node_modules", 6 | "playground", 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module'], 3 | devtools: { enabled: true }, 4 | compatibilityDate: '2024-09-09', 5 | }) 6 | -------------------------------------------------------------------------------- /test/fixtures/basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import MyModule from '../../../src/module' 2 | 3 | export default defineNuxtConfig({ 4 | modules: [ 5 | MyModule, 6 | ], 7 | }) 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "^3.13.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground/server/validators/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const createUserValidator = z.object({ 4 | email: z.string().email({ message: 'Invalid email' }), 5 | password: z 6 | .string() 7 | .min(8, { message: 'Password must be at least 8 characters' }) 8 | .max(256, { message: 'Password must be at most 256 characters' }), 9 | avatar: z.any().optional(), 10 | }) 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0.6 4 | 5 | [compare changes](https://github.com/LouisLSCHVN/nuxt-form/compare/v1.0.5...v1.0.6) 6 | 7 | ### 🏡 Chore 8 | 9 | - **release:** V1.0.1 ([ebd978b](https://github.com/LouisLSCHVN/nuxt-form/commit/ebd978b)) 10 | 11 | ### ❤️ Contributors 12 | 13 | - LouisLSCHVN 14 | 15 | ## v1.0.5 16 | 17 | [compare changes](https://github.com/LouisLSCHVN/nuxt-form/compare/v1.0.4...v1.0.5) 18 | -------------------------------------------------------------------------------- /playground/server/api/endpoint.post.ts: -------------------------------------------------------------------------------- 1 | import { createUserValidator } from '../validators' 2 | 3 | export default defineEventHandler(async (event) => { 4 | const result = await readValidatedFormData(event, createUserValidator.safeParse) 5 | console.log('Received form data', result) 6 | 7 | if (!result.success) { 8 | return createValidationError(result.error) 9 | } 10 | 11 | // Store in database.. 12 | 13 | return { statusCode: 201, message: 'success' } 14 | }) 15 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively 5 | export default createConfigForNuxt({ 6 | features: { 7 | // Rules for module authors 8 | tooling: true, 9 | // Rules for formatting 10 | stylistic: true, 11 | }, 12 | dirs: { 13 | src: [ 14 | './playground', 15 | ], 16 | }, 17 | }) 18 | .append( 19 | // your custom flat config here... 20 | ) 21 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils/e2e' 4 | 5 | describe('ssr', async () => { 6 | await setup({ 7 | rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), 8 | }) 9 | 10 | it('renders the index page', async () => { 11 | // Get response to a server-rendered page with `$fetch`. 12 | const html = await $fetch('/') 13 | expect(html).toContain('
basic
') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, createResolver, addServerImportsDir, addImportsDir } from '@nuxt/kit' 2 | 3 | // Module options TypeScript interface definition 4 | // export interface ModuleOptions {} 5 | 6 | export default defineNuxtModule({ 7 | meta: { 8 | name: 'nuxt-form', 9 | configKey: 'form', 10 | }, 11 | defaults: {}, 12 | setup(_options, _nuxt) { 13 | const resolver = createResolver(import.meta.url) 14 | 15 | addImportsDir(resolver.resolve('./runtime/composables')) 16 | addServerImportsDir(resolver.resolve('./runtime/server/utils')) 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /src/runtime/server/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { createError } from 'h3' 2 | 3 | export interface ValidationError { 4 | field: string 5 | message: string 6 | } 7 | 8 | export function createValidationError(err: unknown): Error { 9 | console.log(err) 10 | if (!err) { 11 | return createError({ statusCode: 500, message: 'Something went wrong' }) 12 | } 13 | const errors: ValidationError[] = err.errors.map((err: ValidationError) => ({ 14 | field: err.path.join('.'), 15 | message: err.message, 16 | })) 17 | 18 | return createError({ 19 | statusCode: 422, 20 | statusMessage: 'Unprocessable Entity', 21 | data: { 22 | errors: errors, 23 | }, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - run: corepack enable 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Install dependencies 23 | run: npx nypm@latest i 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - run: corepack enable 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | 38 | - name: Install dependencies 39 | run: npx nypm@latest i 40 | 41 | - name: Playground prepare 42 | run: npm run dev:prepare 43 | 44 | - name: Test 45 | run: npm run test 46 | -------------------------------------------------------------------------------- /src/runtime/server/utils/form-data.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event, InferEventInput, MultiPartData, ValidateFunction, ValidateResult } from 'h3' 2 | import { readValidatedBody, readMultipartFormData, getHeader } from 'h3' 3 | 4 | import { createValidationError } from './validation' 5 | 6 | export async function readValidatedFormData< 7 | T, 8 | Event extends H3Event = H3Event, 9 | _T = InferEventInput<'body', Event, T>, 10 | >(event: Event, validate: ValidateFunction<_T>): Promise> { 11 | const contentType = getHeader(event, 'content-type') 12 | 13 | if (!contentType?.includes('multipart/form-data')) { 14 | return await readValidatedBody(event, validate) 15 | } 16 | const formData = await readMultipartFormData(event) 17 | if (!formData) { 18 | throw createValidationError('No form data received') 19 | } 20 | 21 | const formDataObj = formatFormData(formData) 22 | return validate(formDataObj) 23 | } 24 | 25 | const formatFormData = (formData: MultiPartData[]) => { 26 | const formDataObj: Record = {} 27 | formData.forEach((field) => { 28 | if (field.name) { 29 | formDataObj[field.name] = field.type === 'file' ? field : field.data.toString() 30 | } 31 | }) 32 | return formDataObj 33 | } 34 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 73 | 74 | 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@louislschvn/nuxt-form", 3 | "version": "1.0.6", 4 | "description": "Add a useForm composable to Nuxt, and a createValidationError function to validate forms in Nitro", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/LouisLSCHVN/nuxt-form.git" 8 | }, 9 | "license": "MIT", 10 | "type": "module", 11 | "exports": { 12 | ".": { 13 | "types": "./dist/types.d.ts", 14 | "import": "./dist/module.mjs", 15 | "require": "./dist/module.cjs" 16 | } 17 | }, 18 | "main": "./dist/module.cjs", 19 | "types": "./dist/types.d.ts", 20 | "files": [ 21 | "dist" 22 | ], 23 | "scripts": { 24 | "prepack": "nuxt-module-build build", 25 | "dev": "nuxi dev playground", 26 | "dev:build": "nuxi build playground", 27 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 28 | "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish --access public && git push --follow-tags", 29 | "lint": "eslint .", 30 | "test": "vitest run", 31 | "test:watch": "vitest watch", 32 | "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit" 33 | }, 34 | "dependencies": { 35 | "@nuxt/kit": "^3.13.1", 36 | "zod": "^3.23.8" 37 | }, 38 | "devDependencies": { 39 | "@nuxt/devtools": "^1.4.1", 40 | "@nuxt/eslint-config": "^0.5.5", 41 | "@nuxt/module-builder": "^0.8.3", 42 | "@nuxt/schema": "^3.13.1", 43 | "@nuxt/test-utils": "^3.14.1", 44 | "@types/node": "latest", 45 | "changelogen": "^0.5.5", 46 | "eslint": "^9.9.1", 47 | "nuxt": "^3.13.0", 48 | "typescript": "latest", 49 | "vitest": "^2.0.5", 50 | "vue-tsc": "^2.1.6" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Form 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![License][license-src]][license-href] 6 | [![Nuxt][nuxt-src]][nuxt-href] 7 | 8 | My new Nuxt module for doing amazing things. 9 | 10 | - [✨  Release Notes](/CHANGELOG.md) 11 | 12 | 13 | 14 | ## Features 15 | 16 | - ⛰  Form handling similar to Inertia/Vue's useForm 17 | - 🚠  Backend validation with Zod 18 | - 🌲  Frontend form state management 19 | - 🏔  Easy error handling and validation 20 | 21 | ## Quick Setup 22 | 23 | Install the module to your Nuxt application with one command: 24 | 25 | ```bash 26 | npx nuxi module add @louislschvn/nuxt-form 27 | ``` 28 | 29 | That's it! You can now use Nuxt Form in your Nuxt app ✨ 30 | 31 | ## Documentation 32 | 33 | ### Backend Usage 34 | 35 | Nuxt Form provides a utility function `createValidationError` for easy backend validation: 36 | 37 | ```javascript 38 | import { createUserValidator } from '../validators' 39 | 40 | export default defineEventHandler(async (event) => { 41 | const result = await readValidatedBody(event, createUserValidator.safeParse) 42 | if (!result.success) { 43 | return createValidationError(result.error) 44 | } 45 | // Store in database.. 46 | return { statusCode: 201, message: 'success' } 47 | }) 48 | ``` 49 | 50 | ### Frontend Usage 51 | 52 | On the frontend, you can use the `useForm` composable to handle form state and submission: 53 | 54 | ```vue 55 | 67 | 68 | 90 | ``` 91 | 92 | This setup provides a seamless integration between frontend form handling and backend validation, similar to the functionality offered by Inertia.js and Vue's useForm, but tailored for Nuxt applications. 93 | 94 | ## File Uploads 95 | 96 | Nuxt Form also supports file uploads in a simple and efficient way. You can manage file uploads alongside your form data, and it even provides upload progress tracking. 97 | 98 | ### Example of File Upload 99 | 100 | In the example below, you can see how to add a file upload input to your form. The file selected will be sent with the rest of the form data when submitted. 101 | 102 | ```vue 103 | 125 | 126 | 135 | ``` 136 | 137 | ### Key Features of File Handling: 138 | 139 | - Automatically manage file uploads with the form data. 140 | - Track upload progress with a visual progress bar. 141 | 142 | ## Todo 143 | 144 | - [X] Handle file uploads 145 | - [X] Make success state natively available in the composable 146 | 147 | ## Contribution 148 | 149 |
150 | Local development 151 | 152 | ```bash 153 | # Install dependencies 154 | npm install 155 | 156 | # Generate type stubs 157 | npm run dev:prepare 158 | 159 | # Develop with the playground 160 | npm run dev 161 | 162 | # Build the playground 163 | npm run dev:build 164 | 165 | # Run ESLint 166 | npm run lint 167 | 168 | # Run Vitest 169 | npm run test 170 | npm run test:watch 171 | 172 | # Release new version 173 | npm run release 174 | ``` 175 | 176 |
177 | 178 | 179 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-form/latest.svg?style=flat&colorA=020420&colorB=00DC82 180 | [npm-version-href]: https://npmjs.com/package/nuxt-form 181 | 182 | [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-form.svg?style=flat&colorA=020420&colorB=00DC82 183 | [npm-downloads-href]: https://npmjs.com/package/nuxt-form 184 | 185 | [license-src]: https://img.shields.io/npm/l/nuxt-form.svg?style=flat&colorA=020420&colorB=00DC82 186 | [license-href]: https://npmjs.com/package/nuxt-form 187 | 188 | [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js 189 | [nuxt-href]: https://nuxt.com 190 | -------------------------------------------------------------------------------- /src/runtime/composables/useForm.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPMethod } from 'h3' 2 | import { reactive, ref, toRefs, watch } from 'vue' 3 | 4 | interface FormOptions { 5 | onSuccess?: (data: T) => void 6 | onError?: (error: unknown) => void 7 | onFinish?: () => void 8 | headers?: Record 9 | requestOptions?: { 10 | withCredentials?: boolean 11 | timeout?: number 12 | [key: string]: unknown 13 | } 14 | fetchOptions?: { 15 | [key: string]: unknown 16 | } 17 | } 18 | 19 | interface ValidationError { 20 | field: (string | number)[] 21 | message: string 22 | } 23 | 24 | export const useForm = >(_data: T) => { 25 | const data = ref({ ..._data }) 26 | const originalData = { ..._data } 27 | const errors = reactive>({}) 28 | const processing = ref(false) 29 | const success = ref(false) 30 | const progress = ref(null) 31 | 32 | const setError = (field: keyof T, message: string) => { 33 | errors[field as string] = message 34 | } 35 | 36 | const resetErrors = () => { 37 | Object.keys(errors).forEach((key) => { 38 | errors[key] = '' 39 | }) 40 | } 41 | 42 | const reset = (...fields: (keyof T)[]) => { 43 | if (fields.length === 0) { 44 | Object.keys(originalData).forEach((key) => { 45 | if (data.value[key] instanceof File || data.value[key] instanceof Blob) { 46 | data.value[key] = null 47 | } 48 | else { 49 | data.value[key] = originalData[key] 50 | } 51 | }) 52 | } 53 | else { 54 | fields.forEach((field) => { 55 | if (data.value[field] instanceof File || data.value[field] instanceof Blob) { 56 | data.value[field] = null 57 | } 58 | else { 59 | data.value[field] = originalData[field] 60 | } 61 | }) 62 | } 63 | } 64 | 65 | const handleValidationErrors = (validationErrors: ValidationError[]) => { 66 | resetErrors() 67 | validationErrors.forEach((error) => { 68 | const field = error.field as unknown as string 69 | setError(field, error.message) 70 | }) 71 | } 72 | 73 | function hasFiles(data: T): boolean { 74 | if (data instanceof File || data instanceof Blob) { 75 | return true 76 | } 77 | 78 | if (Array.isArray(data)) { 79 | return data.some(value => hasFiles(value)) 80 | } 81 | 82 | if (typeof data === 'object' && data !== null) { 83 | return Object.values(data).some(value => hasFiles(value as unknown as T)) 84 | } 85 | 86 | return false 87 | } 88 | const hasFile = ref(hasFiles(data.value)) 89 | 90 | watch( 91 | data.value, 92 | (newValue: T) => { hasFile.value = hasFiles(newValue) }, 93 | { deep: true }, 94 | ) 95 | 96 | const appendFormData = (formData: FormData) => { 97 | objectToFormData(formData, data.value) 98 | } 99 | 100 | function objectToFormData(formData: FormData, data: T, parentKey?: string) { 101 | if (data instanceof File || data instanceof Blob) { 102 | if (parentKey) { 103 | formData.append(parentKey, data) 104 | } 105 | } 106 | else if (Array.isArray(data)) { 107 | data.forEach((value, index) => { 108 | const key = parentKey ? `${parentKey}[${index}]` : `${index}` 109 | objectToFormData(formData, value, key) 110 | }) 111 | } 112 | else if (typeof data === 'object' && data !== null) { 113 | Object.keys(data).forEach((key) => { 114 | const value = data[key] 115 | const formKey = parentKey ? `${parentKey}[${key}]` : key 116 | objectToFormData(formData, value as unknown as T, formKey) 117 | }) 118 | } 119 | else if (data !== null && data !== undefined) { 120 | if (parentKey) { 121 | formData.append(parentKey, String(data)) 122 | } 123 | } 124 | } 125 | 126 | const submitFetch = async (method: HTTPMethod, url: string, options: FormOptions) => { 127 | try { 128 | const requestData = data.value 129 | resetErrors() 130 | processing.value = true 131 | const res = await $fetch(url, { 132 | method, 133 | body: method !== 'GET' ? requestData : undefined, 134 | params: method === 'GET' ? requestData : undefined, 135 | ...options.fetchOptions, 136 | }) 137 | processing.value = false 138 | options.onSuccess?.(res as T) 139 | success.value = true 140 | } 141 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 142 | catch (err: any) { 143 | const validationErrors = err.data?.data?.errors 144 | processing.value = false 145 | if (err.statusCode === 422 && Array.isArray(validationErrors)) { 146 | handleValidationErrors(validationErrors) 147 | } 148 | options.onError?.(err) 149 | } 150 | finally { 151 | options.onFinish?.() 152 | processing.value = false 153 | } 154 | } 155 | 156 | const submitXhr = (method: HTTPMethod, url: string, options: FormOptions) => { 157 | try { 158 | resetErrors() 159 | processing.value = true 160 | progress.value = 0 161 | 162 | const requestData = new FormData() 163 | appendFormData(requestData) 164 | 165 | let requestUrl = url 166 | if (method === 'GET' || method === 'DELETE') { 167 | const queryParams = new URLSearchParams() 168 | requestData.forEach((value, key) => { 169 | queryParams.append(key, value.toString()) 170 | }) 171 | requestUrl += '?' + queryParams.toString() 172 | } 173 | 174 | const xhr = new XMLHttpRequest() 175 | 176 | xhr.open(method, requestUrl, true) 177 | 178 | if (options.requestOptions) { 179 | for (const [key, value] of Object.entries(options.requestOptions)) { 180 | if (key in xhr && typeof xhr[key as keyof XMLHttpRequest] !== 'function') { 181 | xhr[key as keyof XMLHttpRequest] = value 182 | } 183 | } 184 | } 185 | 186 | if (options.headers) { 187 | for (const [key, value] of Object.entries(options.headers)) { 188 | xhr.setRequestHeader(key, value) 189 | } 190 | } 191 | 192 | xhr.upload.onprogress = (event) => { 193 | if (event.lengthComputable) { 194 | progress.value = Math.round((event.loaded / event.total) * 100) 195 | } 196 | } 197 | 198 | xhr.onload = () => { 199 | progress.value = null 200 | 201 | let responseData 202 | try { 203 | responseData = JSON.parse(xhr.responseText) 204 | } 205 | catch (e) { 206 | console.error(e) 207 | responseData = xhr.responseText 208 | } 209 | 210 | if (xhr.status >= 200 && xhr.status < 300) { 211 | options.onSuccess?.(responseData as T) 212 | success.value = true 213 | } 214 | else { 215 | if (xhr.status === 422 && responseData?.data?.errors) { 216 | handleValidationErrors(responseData.data.errors) 217 | } 218 | options.onError?.(responseData) 219 | } 220 | } 221 | 222 | xhr.onloadend = () => { 223 | processing.value = false 224 | options.onFinish?.() 225 | } 226 | 227 | xhr.onerror = () => { 228 | processing.value = false 229 | progress.value = null 230 | 231 | let responseData 232 | try { 233 | responseData = JSON.parse(xhr.responseText) 234 | } 235 | catch (e) { 236 | console.error(e) 237 | responseData = xhr.responseText 238 | } 239 | 240 | options.onError?.(responseData) 241 | options.onFinish?.() 242 | } 243 | 244 | xhr.onabort = () => { 245 | processing.value = false 246 | progress.value = null 247 | options.onFinish?.() 248 | } 249 | 250 | xhr.ontimeout = () => { 251 | processing.value = false 252 | progress.value = null 253 | options.onError?.(new Error('Request timed out')) 254 | options.onFinish?.() 255 | } 256 | 257 | if (method === 'GET' || method === 'DELETE') { 258 | xhr.send() 259 | } 260 | else { 261 | xhr.send(requestData) 262 | } 263 | } 264 | catch (err: unknown) { 265 | processing.value = false 266 | progress.value = null 267 | options.onError?.(err) 268 | options.onFinish?.() 269 | } 270 | } 271 | 272 | const submit = (method: HTTPMethod, url: string, options: FormOptions) => { 273 | const requestHandler = hasFile.value ? submitXhr : submitFetch 274 | return requestHandler(method, url, options) 275 | } 276 | 277 | const get = (url: string, options?: FormOptions) => submit('GET', url, options || {}) 278 | const post = (url: string, options?: FormOptions) => submit('POST', url, options || {}) 279 | const put = (url: string, options?: FormOptions) => submit('PUT', url, options || {}) 280 | const patch = (url: string, options?: FormOptions) => submit('PATCH', url, options || {}) 281 | const destroy = (url: string, options?: FormOptions) => submit('DELETE', url, options || {}) 282 | 283 | const transform = (transformer: (data: T) => Partial) => { 284 | const transformedData = transformer(data.value) 285 | Object.assign(data.value, transformedData) 286 | return { 287 | post, 288 | put, 289 | patch, 290 | destroy, 291 | } 292 | } 293 | 294 | return reactive({ 295 | ...toRefs(data.value), 296 | value: data, 297 | errors, 298 | processing, 299 | success, 300 | progress, 301 | reset, 302 | resetErrors, 303 | setError, 304 | submit, 305 | get, 306 | post, 307 | put, 308 | patch, 309 | destroy, 310 | transform, 311 | }) 312 | } 313 | --------------------------------------------------------------------------------