A Vuetify-powered drag-and-drop form builder and form renderer
module for Nuxt 3 and Nuxt 4 — with a built-in Jalali + Gregorian
date picker, custom progress indicator, and full pixel-parity with the
DataGenCloud Forms feature it was extracted from. Companion to
nuxt-toast-notification,
nuxt-confirm-dialog,
and nuxt-input-dialog.
- Three core components:
<FormBuilder>,<FormRenderer>,<FormPreview> - Plus:
<FormList>,<FormLoader>,<FormDatePicker>,<FormProgress> - Schema-first: a versioned plain-JSON schema you can store anywhere
- Storage-agnostic: events return JSON; an optional
FormStorageAdapterinterface powers the list/loader helpers - Vuetify 3 throughout: dialogs, data tables, buttons, animations, focus management — all from the Vuetify library, no hand-rolled CSS framework
- Jalali + Gregorian: built-in
<FormDatePicker>switches calendars on the fly, with no third-party picker dependency - TypeScript-first: every public type is exported
v1.0.0 — first stable release. Full DataGenCloud Forms parity on
Vuetify 3.9: drag-and-drop builder with auto-generated field names, an
interactive preview, Jalali + Gregorian <FormDatePicker>,
<FormProgress>, list/loader with confirm-guarded delete, and optional
toast / confirm-dialog integration. 120 unit tests passing.
Migrating from v0.x? The persisted JSON schema is 100% compatible — only the runtime requirements changed (Vuetify 3.9 + Vue 3.5 + MDI). See the
CHANGELOG.
| Peer | Range | Required |
|---|---|---|
vue |
^3.5.0 |
yes |
vuetify |
^3.9.0 |
yes (3.9+ — earlier 3.x white-screens under Nuxt 4 / unhead v2) |
@mdi/font |
^7.4.0 |
yes (icon set) |
nuxt-toast-notification |
^1.1.0 |
optional (notify integration) |
nuxt-confirm-dialog |
^1.1.0 |
optional (confirm integration) |
npm install nuxt-form-management vuetify @mdi/font
npm install -D vite-plugin-vuetify sass
# optional integrations:
npm install nuxt-toast-notification nuxt-confirm-dialognuxt.config.ts — register vite-plugin-vuetify, transpile Vuetify,
and load the stylesheets via the css: array (importing
vuetify/styles from inside a CSS file does not resolve reliably
under Vite — use the array):
import vuetify from 'vite-plugin-vuetify'
export default defineNuxtConfig({
modules: ['nuxt-form-management'],
css: ['vuetify/styles', '@mdi/font/css/materialdesignicons.css'],
build: { transpile: ['vuetify'] },
vite: {
ssr: { noExternal: ['vuetify'] },
plugins: [vuetify({ autoImport: true })],
},
})Register Vuetify with a universal plugin (not .client — <v-app>
renders during SSR and a client-only plugin throws a Vuetify injection
error):
// plugins/vuetify.ts ← universal, NOT vuetify.client.ts
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(createVuetify({
ssr: true,
components,
directives,
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
theme: { defaultTheme: 'formDark', themes: { formDark: { dark: true } } },
}))
})Wrap your app in
<v-app>(e.g. inapp.vue) so Vuetify's theme CSS variables are available to the components.
<script setup lang="ts">
import type { FormSchema } from 'nuxt-form-management'
const { createSchema } = useFormSchema()
const schema = ref<FormSchema>(createSchema({ formName: 'Daily inspection' }))
function onSave(s: FormSchema) {
// s is plain JSON. Send it wherever you want — REST, GraphQL, IndexedDB...
return $fetch('/api/forms', { method: 'POST', body: s })
}
</script>
<template>
<FormBuilder v-model:schema="schema" @save="onSave" />
</template><script setup lang="ts">
import type { FormSchema, FormValues } from 'nuxt-form-management'
const { data: schema } = await useFetch<FormSchema>('/api/forms/abc')
function onSubmit(values: FormValues) {
// values is `{ fieldName: typedValue }`. Numbers are numbers, checkboxes
// are booleans, dates are ISO-style strings — coerced per field type.
return $fetch('/api/submissions', { method: 'POST', body: { values } })
}
</script>
<template>
<FormRenderer :schema="schema" @submit="onSubmit" />
</template><FormPreview :schema="schema" :submission="{ values: { operator: 'Alice' } }" /><FormDatePicker> is also usable on its own anywhere in your app:
<script setup lang="ts">
const date = ref('2024-03-20')
</script>
<template>
<FormDatePicker
v-model="date"
calendar="jalali"
date-mode="single"
label="Choose a date"
/>
<!-- date is always Gregorian ISO-8601, regardless of the calendar view -->
</template>formManagement: {
prefix: 'Form', // <FormBuilder>, <FormRenderer>, ... — change to 'My' → <MyBuilder> etc.
theme: 'formDark', // Name of a Vuetify theme registered in createVuetify()
iconSet: 'mdi', // Default icon set; pass 'mdi-svg' / 'fa' etc. if you use a different Vuetify icon set
}The module never makes network calls. Two ways to persist data:
<FormBuilder
v-model:schema="schema"
@save="schema => api.saveForm(schema)"
@preview="schema => track('preview-opened')"
/>
<FormRenderer
:schema="schema"
@submit="values => api.saveSubmission({ formId: schema.formId, values })"
@validation-failure="errors => track('validation-failed')"
/>This unlocks <FormList> and <FormLoader> and gives you a single
contract you implement once.
// plugins/form-management.client.ts
import type { FormStorageAdapter } from 'nuxt-form-management'
const adapter: FormStorageAdapter = {
async listForms(filter) { return $fetch('/api/forms', { query: filter }) },
async getForm(id) { return $fetch(`/api/forms/${id}`) },
async saveForm(schema) { await $fetch('/api/forms', { method: 'POST', body: schema }) },
async deleteForm(id) { await $fetch(`/api/forms/${id}`, { method: 'DELETE' }) },
async listSubmissions({ formId }) { return $fetch('/api/submissions', { query: { formId } }) },
async getSubmission(id) { return $fetch(`/api/submissions/${id}`) },
async saveSubmission(submission) { await $fetch('/api/submissions', { method: 'POST', body: submission }) },
async deleteSubmission(id) { await $fetch(`/api/submissions/${id}`, { method: 'DELETE' }) },
}
export default defineNuxtPlugin(() => {
useFormManagement().registerAdapter(adapter)
})nuxt-toast-notification and nuxt-confirm-dialog are optional peer
dependencies. Install them, add both to modules, then bridge them to
this package's notify / confirm hooks in one client plugin:
// nuxt.config.ts
modules: [
'nuxt-form-management',
'nuxt-toast-notification',
'nuxt-confirm-dialog',
]// plugins/form-management.client.ts
export default defineNuxtPlugin(() => {
const fm = useFormManagement()
fm.registerAdapter(adapter)
// useToast() / useConfirmDialog() are auto-imported by the two modules
const toast = useToast()
const dialog = useConfirmDialog()
fm.registerNotify((level, title, message) => {
toast[level](title, message ?? '')
})
fm.registerConfirm(async (opts) => {
const action = await dialog.show({
type: opts.type === 'danger' ? 'error' : (opts.type ?? 'warning'),
title: opts.title,
message: opts.message ?? '',
confirmText: opts.confirmLabel,
cancelText: opts.cancelLabel,
})
return action === 'confirm'
})
})With this wired, the builder's save validation, <FormList> /
submission deletes etc. surface as toasts and confirm dialogs. Without
it, the components fall back to console.warn for notify and
always-true for confirm.
interface FormSchema {
schemaVersion: 1
formId: string
formName: string
rows: Array<{
id: string
columns: Array<{
id: string
colSpan: number // 1-12; sum of colSpans in a row MUST equal 12
field: FormField
}> // 1-3 columns per row
}>
metadata?: Record<string, unknown>
createdAt?: string
updatedAt?: string
}type |
Required props | Optional props |
|---|---|---|
text |
id, name, label, required |
placeholder |
number |
same | unit (e.g. "kg") |
select |
same + options: string[] |
|
checkbox |
same | |
textarea |
same | |
date |
same + dateMode, dateType |
calendar ('gregorian' | 'jalali'), dateColumns, placeholder |
name MUST match /^[a-zA-Z_][a-zA-Z0-9_]*$/ and be unique within a
form. Renderers use name as the submission's key.
Date values in submissions are always Gregorian ISO-8601 strings
(YYYY-MM-DD) regardless of which calendar the user picks dates in.
This keeps the persisted schema deterministic.
A renderer running an older schemaVersion will:
- log one
console.warnper unknown field type - skip rendering the unknown field
- omit it from the
@submitpayload
So a v2 schema field can be added without breaking v1 consumers.
For backends that store field metadata as a single colon-separated string (the format DataGenCloud uses):
import { parseLegacy, serializeLegacy } from 'nuxt-form-management/legacy'
const parsed = parseLegacy(
'rowIndex:0, colIndex:0, colSpan:6, label:Operator, options:[A,B,C]',
'select',
)The canonical schema is JSON. Use these only when interfacing with a legacy column.
| Prop | Type | Description |
|---|---|---|
schema |
FormSchema | null |
v-model-able schema. |
isSaving |
boolean |
Disables the Save button while in flight. |
saveLabel |
string |
Override the Save button label. |
onValidationFailure |
(title, message) => void |
Called on validation-fail save. Falls back to the registered notify handler. |
| Event | Payload | When |
|---|---|---|
update:schema |
FormSchema |
Any internal change (v-model). |
save |
FormSchema |
Save button clicked + schema is valid. |
preview |
FormSchema |
Preview dialog opened. |
Dropping a field auto-generates its name as <type>_<counter>
(e.g. text_1, select_2) — unique across the form; the label is
left blank for the designer to fill (and is required to save). The
built-in Preview is interactive — you can type, select and pick
dates to test the form exactly as a respondent would.
| Slot | Scope props | Description |
|---|---|---|
filters |
{ schema, update } |
Inject custom metadata fields above the canvas. |
| Prop | Type | Description |
|---|---|---|
schema |
FormSchema | null |
The form to render. |
initialSubmission |
FormSubmission | FormValues | null |
Pre-fills inputs. |
readonly |
boolean |
Lock all inputs and hide Submit. |
hideActions |
boolean |
Hide bundled Submit; render your own via the actions slot. |
submitLabel |
string |
Override the Submit button label. |
hideTitle |
boolean |
Don't render formName as a heading. |
density |
'comfortable' | 'default' | 'compact' |
Forwarded to every Vuetify input. |
onValidationFailure |
(title, message, errors) => void |
Falls back to notify if not provided. |
| Event | Payload | When |
|---|---|---|
update:submission |
FormValues |
Any input edit. |
submit |
FormValues |
Submit clicked + validation passed. |
validation-failure |
SubmissionError[] |
Submit clicked + validation failed. |
| Slot | Scope props | Description |
|---|---|---|
actions |
{ submit, reset, isSubmitting } |
Replace the bundled Submit button. |
date-field |
{ field, modelValue, onUpdate, readonly, hasError } |
Replace the built-in date picker. |
empty |
— | Shown when schema has no fields. |
Read-only sibling of <FormRenderer>. Same schema + submission
props, no events, all inputs disabled / readonly. Same date-field and
empty slots.
Renders a Vuetify v-data-table of forms returned by the registered
adapter. Emits @edit, @load, @delete. Shows a no-adapter empty
state when no adapter is registered. Loading state uses
v-skeleton-loader rows.
A "pick one of my saved forms" widget. filters slot lets you inject
custom criteria forwarded to adapter.listForms(). Loading state uses
<FormProgress>.
Self-contained Jalali + Gregorian date picker built on Vuetify.
| Prop | Type | Default |
|---|---|---|
modelValue |
string | string[] | null |
null |
calendar |
'gregorian' | 'jalali' |
'gregorian' |
dateType |
'date' | 'datetime' | 'time' |
'date' |
dateMode |
'single' | 'range' | 'multi' |
'single' |
label, placeholder |
string |
'' |
readonly, required, error |
boolean |
false |
errorMessages |
string | string[] |
[] |
density |
'comfortable' | 'default' | 'compact' |
'comfortable' |
| Event | Payload |
|---|---|
update:modelValue |
string | string[] | null (Gregorian ISO-8601) |
Custom modern progress indicator (v-progress-circular + scoped
gradient + glow pulse). Use it directly for any loading state in your
app that should match the package's look.
| Prop | Type | Default |
|---|---|---|
size |
number |
56 |
width |
number |
4 |
color |
string |
'primary' |
label |
string |
'' |
<FormBuilder> and friends consume Vuetify's theme via useTheme().
Define your themes in createVuetify({ theme: { themes: { ... } } })
and pass the theme name to the module options:
formManagement: {
theme: 'myDarkTheme',
}A few scoped CSS variables (drop-zone animations, hover-lift shadows)
also read from --v-theme-primary, so they track your accent color
automatically.
If your app uses Persian script, import the bundled Shabnam font:
// nuxt.config.ts
css: ['nuxt-form-management/fonts/shabnam.css']npm install
npm run dev:prepare # build module stubs + prepare playground
npm run dev # start the playground (Nuxt dev server)
npm run lint
npm run test
npm run test:coverage
npm run prepack # build dist/MIT — see LICENSE.