DynamicFormLayouts
Panoramica
DynamicFormLayouts è il componente form dinamico principale di @pzeta/vue-form. È il motore usato internamente da DynamicFormDialog, DynamicFormDrawer e DynamicFormDrawerWithTable.
Caratteristiche chiave:
- Validazione on-blur per singolo campo: ogni campo viene validato quando perde il focus, mostrando l'errore inline sotto il componente.
- Layout multipli: rendering normale +
tabs,wizard(stepper),accordion. - Tracking dirty/valid reattivo:
v-model:dirty,v-model:dirty-values,v-model:validper integrazione con logica di salvataggio. - Cross-field validation:
validation-rulesper regole tra campi (match, different, requiredIf, ecc.) valutate al cambio diformDatatramitevalidateCrossFields. - Condizioni dinamiche:
disabledevisiblesu campo/sezione accettanoboolean | FieldCondition | (data) => boolean(predicate function per logica complessa). - Esclusione campi dal submit:
field.exclude = true+ tracking automatico viaexcludedFieldsMap. - Propagazione globale
disabled/readonly: i figli ricevono lo stato viainject('dynamicFormDisabled')/inject('dynamicFormReadonly'). - Submit nativo opzionale: con
withSubmitil form viene wrappato in<form @submit>ed emettesubmitcon i valori validi (campiexcludefiltrati). - Validazione al mount governata da
isEdit: in modalitàisEdit: truetutti i campi vengono validati almounted(utile per evidenziarerequiredvuoti su record legacy con dati incompleti); in modalità insert (isEdit: false) il mount è silenzioso — il pulsante save resta comunque disabled grazie aupdate:validderivato live dalformData. - Wizard lineare con guard di avanzamento: in
displayMode='wizard',wizardLinear(defaulttrue) blocca lo Stepper sugli step non ancora visitati e disabilita "Avanti" finché la sezione corrente non è valida. Le sezioni convisible: falsevengono saltate automaticamente. - Metodi esposti via
defineExpose:validate(),reset(),initializeForm(),values, e i wizard helper (wizardCanAdvance,wizardIsFirstStep,wizardIsLastStep,wizardGoPrev,wizardGoNext) accessibili da untemplate ref. - Re-init automatico al cambio schema: se la reference di
props.schemacambia, il form viene ri-inizializzato eactiveSteptorna a'0'.
Anteprima — validazione on-blur
Compila i campi e clicca fuori (blur) per vedere gli errori inline. Lo stato "Form valido" si aggiorna in tempo reale.
Schema FormSchema
interface FormSchema {
sections: SectionDefinition[]
}
interface SectionDefinition {
title?: string
subtitle?: string
icon?: string // PrimeIcons class (es. 'pi pi-user')
rows?: RowDefinition[]
layout?: 'vertical' | 'horizontal' | 'grid' // disposizione interna
columns?: number // numero colonne griglia
collapsible?: boolean // sezione comprimibile
collapsed?: boolean // stato iniziale collassato
visible?: boolean | FieldCondition // visibilità (booleano o condizione)
class?: string // classi CSS aggiuntive
}
interface RowDefinition {
fields: FieldDefinition[]
class?: string
}
interface FieldDefinition {
name: string // chiave nel formData
type: InputType // 'text' | 'select' | 'date' | ...
label?: string
placeholder?: string
// Help: stringa statica oppure funzione reattiva
help?: string | ((value: unknown, formData: Record<string, unknown>) => string)
validation?: string // 'required|email|min:5'
required?: boolean // shortcut per validation 'required'
disabled?: FieldVisibility // boolean | FieldCondition | predicate
readonly?: boolean
visible?: FieldVisibility // boolean | FieldCondition | predicate
exclude?: boolean // escludi dal submit
defaultValue?: unknown
props?: Record<string, unknown> // props inoltrate al componente input
span?: number // 1-12 (griglia 12 colonne)
class?: string
inplace?: boolean // editing inplace
// Bridge UI <-> formData per campi computed/virtuali
transform?: {
read: (formData: Record<string, unknown>) => unknown
write: (uiValue: unknown, formData: Record<string, unknown>) =>
Partial<Record<string, unknown>>
}
// Hook reattivo post-commit per derivare altri valori del formData
onChange?: (
value: unknown,
formData: Record<string, unknown>,
patch: (fields: Record<string, unknown>) => void,
) => void
}
// Condizione dichiarativa
interface FieldCondition {
field: string // nome campo da valutare
operator: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'contains' | 'empty' | 'notEmpty'
value?: unknown
}
// Funzione di predicato (per logica complessa)
type FieldPredicate = (formData: Record<string, unknown>) => boolean
// Tipo accettato da `disabled` / `visible` (sia su field che su section)
type FieldVisibility = boolean | FieldCondition | FieldPredicate
boolean e FieldCondition, disabled/visible accettano una funzione (data) => boolean per logica complessa che combina più campi. Esempio: visible: (d) => d.tipo === 'azienda' && !d.skip.Lo schema è gerarchico:
sections→rows→fields. Le righe rendono i campi su una griglia con colonne uguali (grid-template-columns: repeat(N, minmax(0, 1fr))); confield.spanpuoi assegnare una larghezza diversa al singolo campo.
Props
| Prop | Tipo | Default | Descrizione |
|---|---|---|---|
schema | FormSchema | — | Schema del form (sections > rows > fields) |
modelValue | Record<string, unknown> | {} | Valori del form (v-model) |
displayMode | 'normal' | 'tabs' | 'wizard' | 'accordion' | 'normal' | Modalità di visualizzazione |
initialData | Record<string, unknown> | {} | Dati iniziali per calcolo dirty |
validationRules | CrossFieldValidationRule[] | [] | Regole cross-field (match, different, ecc.) |
dirty | boolean | false | Form modificato (v-model:dirty) |
dirtyValues | Record<string, unknown> | {} | Solo campi modificati (v-model:dirty-values) |
valid | boolean | true | Form valido (v-model:valid) |
activeStep | string | '0' | Step corrente per tabs/wizard (v-model:active-step) |
disabled | boolean | false | Disabilita tutto il form (propagato ai figli via provide) |
readonly | boolean | false | Modalità sola lettura (propagato ai figli via provide) |
withSubmit | boolean | false | Wrappa il form in <form @submit> (solo displayMode='normal') |
wizardLinear | boolean | true | Solo displayMode='wizard'. Quando true lo Stepper di @pzeta/vue-components riceve :linear="true": gli step non ancora visitati sono bloccati (cliccabili solo dopo che l'utente li ha raggiunti tramite "Avanti") e il pulsante "Avanti" è disabilitato finché la sezione corrente non è valida. Setta false per consentire navigazione libera tra step (utile in edit di record già compilati). In isEdit: true tutti gli step sono pre-marcati come "visitati" → la modalità linear non blocca la navigazione anche con wizardLinear: true |
isEdit | boolean | false | Modalità modifica vs creazione del form contenitore. Effetti: (1) valida tutti i campi al mounted — utile per evidenziare required vuoti su record legacy; (2) attiva la validazione on-blur dei campi; (3) in displayMode='wizard' marca tutti gli step come "visitati" già al mount, rilassando il blocco di wizardLinear; (4) viene esposto come slot prop in section-before-{idx} / section-after-{idx} per rendering condizionale. In modalità insert (false) il mount è silenzioso e il blur non triggera la validazione |
submitting | boolean | false | Stato di submitting controllato dal parent. Esposto come slot prop di section-before-{idx} / section-after-{idx} per disabilitare contenuto custom durante un save async |
devMode | boolean | false | Mostra info debug (sfondo evidenziato sulle sezioni) |
isEdit: oltre a essere propagata negli slot scoped delle sezioni, modifica due comportamenti interni:- mount: in
isEdit: trueviene chiamatovalidate()almounted(popola gli errori deirequiredvuoti per record legacy). InisEdit: falsenessuna validazione iniziale. - blur: in
isEdit: truela validazione on-blur dei singoli campi è attiva (UX per modifica). InisEdit: falseil blur è silenzioso (UX per inserimento da zero — il pulsante save resta comunque disabled grazie aupdate:validcalcolato live).
submitting: solo propagata. Pensata per consumer come DynamicFormDialog che la passano dal proprio stato per permettere al consumatore di disabilitare panel data-aware durante un save async.Display modes
normal (default)
Sezioni renderizzate verticalmente una sotto l'altra, separate da titolo + icona.
tabs
Sezioni come tab orizzontali. Ogni tab corrisponde a una sezione.
wizard
Sezioni come step di un wizard (stepper). Per default è in modalità lineare (wizardLinear: true): l'utente deve completare ogni sezione prima di poter accedere alla successiva. Le sezioni con visible: false vengono saltate automaticamente.
Modalità lineare vs libera (wizardLinear)
| Valore | Comportamento |
|---|---|
true (default) | Step non ancora visitati bloccati nella StepList. Avanzamento solo via "Avanti" e disabilitato finché la sezione corrente è invalida. Tornare indietro è sempre permesso. Indicato per inserimento da zero dove vuoi forzare l'utente a completare ogni step in ordine |
false | Navigazione libera tra tutti gli step. Indicato quando l'utente sta modificando un record già compilato e vuoi permettergli di saltare direttamente alla sezione che lo interessa |
In isEdit: true tutti gli step vengono pre-marcati come "visitati", quindi anche con wizardLinear: true la navigazione è di fatto libera fin dal primo render.
Pulsanti "Indietro / Avanti" custom (esempio con wizard helpers)
Lo Stepper di @pzeta/vue-components non renderizza pulsanti di navigazione: i wrapper esterni (es. il footer di un dialog) li implementano usando i metodi esposti tramite defineExpose.
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicFormLayouts } from '@pzeta/vue-form'
const formRef = ref<InstanceType<typeof DynamicFormLayouts> | null>(null)
const formData = ref({})
const currentStep = ref('0')
const schema = { /* ... */ }
function onSubmit() {
if (!formRef.value?.validate()) return
// Submit reale verso l'API
}
</script>
<template>
<DynamicFormLayouts
ref="formRef"
v-model="formData"
v-model:active-step="currentStep"
:schema="schema"
display-mode="wizard"
:wizard-linear="true"
/>
<div class="flex justify-end gap-2 mt-4">
<Button
label="Indietro"
severity="secondary"
:disabled="formRef?.wizardIsFirstStep?.value"
@click="formRef?.wizardGoPrev()"
/>
<Button
v-if="!formRef?.wizardIsLastStep?.value"
label="Avanti"
:disabled="!formRef?.wizardCanAdvance?.value"
@click="formRef?.wizardGoNext()"
/>
<Button
v-else
label="Salva"
severity="primary"
:disabled="!formRef?.wizardCanAdvance?.value"
@click="onSubmit"
/>
</div>
</template>
wizardIsFirstStep / wizardIsLastStep / wizardCanAdvance sono ComputedRef: per leggere il valore nel template usa .value (formRef?.wizardIsFirstStep?.value).
wizardLinear: false): nessuno step viene bloccato e wizardCanAdvance resta legato comunque alla validità della sezione corrente. Vuol dire che l'utente può navigare liberamente cliccando le testate, ma il pulsante "Avanti" (se lo implementi) resta comunque disabilitato finché lo step corrente è invalido — coerente con l'intento di "non perdere dati incompleti" prima di passare oltre.accordion
Sezioni come pannelli accordion espandibili. Le sezioni con collapsed: true partono chiuse.
Tracking dirty values
v-model:dirty-values espone solo i campi che differiscono da initialData. Utile per inviare solo le modifiche al backend (PATCH parziali).
<!-- Codice reale con API -->
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicFormLayouts } from '@pzeta/vue-form'
const initialData = { nome: 'Mario', cognome: 'Rossi', email: 'mario@test.it' }
const formData = ref({ ...initialData })
const dirtyValues = ref({})
async function handleSave() {
// Invia solo i campi modificati
await fetch('/api/utenti/1', {
method: 'PATCH',
body: JSON.stringify(dirtyValues.value)
})
}
</script>
<template>
<DynamicFormLayouts
v-model="formData"
v-model:dirty-values="dirtyValues"
:initial-data="initialData"
:schema="schema"
/>
<button @click="handleSave">Salva modifiche</button>
</template>
Cross-field validation
La prop validationRules accetta un array di regole valutate sui valori del form. Quando una regola fallisce, v-model:valid diventa false e il messaggio appare a livello di sezione (propagato ai FormSectionRow come crossFieldErrors).
interface CrossFieldValidationRule {
type: 'match' | 'different' | 'requiredIf' | string // built-in + custom
fields: string[] // campi coinvolti nella validazione
message?: string // messaggio errore (override default i18n)
value?: unknown // valore di confronto per 'requiredIf'
}
Tipi built-in
match — valori uguali
Tutti i campi in fields devono avere lo stesso valore. Tipico per conferma password.
{
type: 'match',
fields: ['password', 'confermaPassword'],
message: 'Le password non corrispondono',
}
different — valori distinti
I campi in fields devono avere valori diversi tra loro. Utile per evitare duplicati (es. email primaria vs secondaria).
{
type: 'different',
fields: ['emailPrincipale', 'emailSecondario'],
message: 'Le due email devono essere diverse',
}
requiredIf — obbligatorio se condizione
fields[0] (il campo "richiesto") diventa obbligatorio se fields[1] (il campo "condizione") soddisfa il value. Se value è omesso, basta che fields[1] sia truthy.
// Note obbligatorie se tipo === 'altro'
{
type: 'requiredIf',
fields: ['note', 'tipo'],
value: 'altro',
}
// IBAN obbligatorio se preferenzaPagamento è truthy (qualsiasi valore non falsy)
{
type: 'requiredIf',
fields: ['iban', 'preferenzaPagamento'],
}
Esempio completo
<DynamicFormLayouts
v-model="formData"
:schema="schema"
v-model:valid="isValid"
:validation-rules="[
{ type: 'match', fields: ['password', 'confermaPassword'], message: 'Le password non corrispondono' },
{ type: 'requiredIf', fields: ['note', 'tipo'], value: 'altro' },
]"
/>
Tipi custom
Il tipo string & {} permette di registrare regole custom oltre alle 3 built-in. Le regole vengono rivalutate ad ogni cambio di formData tramite la funzione interna validateCrossFields.
email, partitaiva, iban) usa il campo validation dei FieldDefinition con la sintassi 'required|email'. La cross-field opera invece su tuple di campi.Condizioni dinamiche (FieldCondition o predicate)
Sia field.disabled/field.visible sia section.visible accettano:
boolean— valore staticoFieldCondition— condizione dichiarativa basata su un altro campo(data) => boolean— predicate function per logica complessa
// Codice reale — schema con FieldCondition
const schema = {
sections: [
{
title: 'Cliente',
rows: [
{ fields: [
{
name: 'tipoCliente',
type: 'select',
label: 'Tipo',
props: { options: [
{ label: 'Privato', value: 'privato' },
{ label: 'Azienda', value: 'azienda' }
]}
}
]},
{ fields: [
{
name: 'partitaIva',
type: 'text',
label: 'Partita IVA',
// visibile solo se tipoCliente == 'azienda'
visible: { field: 'tipoCliente', operator: '==', value: 'azienda' },
validation: 'required|partitaiva'
},
{
name: 'codiceFiscale',
type: 'text',
label: 'Codice Fiscale',
// visibile solo se tipoCliente == 'privato'
visible: { field: 'tipoCliente', operator: '==', value: 'privato' },
validation: 'required|codicefiscale'
}
]}
]
}
]
}
Operatori supportati: ==, !=, >, <, >=, <=, contains, empty, notEmpty.
Predicate function
Per condizioni che combinano più campi o richiedono logica imperativa, passa direttamente una funzione:
{
name: 'partitaIva',
type: 'text',
label: 'Partita IVA',
// Visibile solo se cliente è azienda E non è marcato come "anonimo"
visible: (data) => data.tipoCliente === 'azienda' && !data.anonimo,
validation: 'required|partitaiva',
}
formData come parametro. Restituisce true per mostrare/abilitare, false per nascondere/disabilitare. Tutte e tre le forme (boolean/FieldCondition/predicate) sono valutate da evaluateCondition (utility interna esportata).Esclusione campi dal submit (exclude)
Un campo con exclude: true viene renderizzato e validato ma non incluso nel dirty-values né emesso col submit. Utile per campi UI puramente decorativi (es. ricerca opzioni) o campi calcolati lato client.
{ name: 'preview', type: 'tiptap', label: 'Anteprima', exclude: true }
Hook field.onChange per derivazioni reattive
field.onChange è un callback invocato post-commit quando il valore del campo cambia. Permette di derivare altri valori del formData a partire dal valore appena committato — il caso d'uso tipico è il prefill di campi correlati quando l'utente seleziona un tipo, una categoria o un'entità lookup.
interface FieldDefinition {
// ...
onChange?: (
value: unknown, // nuovo valore (post transform.read)
formData: Record<string, unknown>, // snapshot post-commit
patch: (fields: Record<string, unknown>) => void, // setter merge-superficiale
) => void
}
Il terzo parametro patch è un setter che esegue merge superficiale sul formData: ogni chiave/valore passato genera un commit reattivo (con propagazione di update:modelValue + dirty tracking + emissione di un nuovo fieldChange).
Esempio: prefill da lookup tipo
const tipi = ref<TipoAttivita[]>([])
const schema = {
sections: [{
rows: [{
fields: [
{
name: 'idtipoattivita',
type: 'select',
label: 'Tipo attività',
props: { options: tipi.value, optionLabel: 'nome', optionValue: 'idtipoattivita' },
onChange: (newId, _data, patch) => {
if (!newId) return
const tipo = tipi.value.find(t => t.idtipoattivita === newId)
if (!tipo) return
patch({
riepilogo: tipo.riepilogopredefinito,
nota: tipo.notapredefinita,
assegnatari: tipo.idutentepredefinito ? [tipo.idutentepredefinito] : [],
})
},
},
{ name: 'riepilogo', type: 'text', label: 'Riepilogo', validation: 'required|max:125' },
{ name: 'nota', type: 'textarea', label: 'Nota' },
// ...
],
}],
}],
}
Quando l'utente seleziona un nuovo tipo, riepilogo/nota/assegnatari vengono ricalcolati automaticamente. L'utente può poi sovrascrivere i valori prefillati: onChange non viene rilanciato finché non cambia di nuovo il campo sorgente.
onChange vs evento field-change
| Pattern | Quando usarlo |
|---|---|
field.onChange (locale al campo) | Logica derivata legata a un campo specifico (prefill, lookup, ricalcolo locale) |
Evento @field-change (globale) | Logica trasversale che reagisce a qualsiasi cambio (telemetria, sync con uno store esterno, autosave debounced) |
onChange viene invocato prima dell'emissione di fieldChange. Se il callback chiama patch(...), ogni chiave della patch produce a sua volta un nuovo fieldChange per quel campo — utile da considerare se hai listener globali con effetti collaterali.Comportamento avanzato
Tre dettagli di implementazione utili per non avere sorprese.
1. Ordine rispetto a transform.read e transform.write
transform.read interviene al rendering del campo (legge dal formData per popolare il v-model dell'input) — non al commit. Il flusso completo quando l'utente modifica un campo è:
input UI emette nuovo valore
│
▼
[se field.transform.write] → produce patch atomica
↓
emit fieldCommit(k, v) per ogni chiave della patch
[else] → emit fieldCommit(field.name, value)
│
▼
field.onChange(value, formData, patch) ← invocato qui
│
▼
emit fieldChange(field.name, value, formData)
Conseguenze pratiche:
- Il primo argomento
valuericevuto daonChangeè il valore UI grezzo dell'input (lo stesso tipo chetransform.readprodurrebbe dalformData) — non i valori atomici espansi datransform.write. Se il campo è virtuale con untransform.writeche esplode in più chiavi, leggi il risultato espanso direttamente daformDatadentroonChange, non davalue. - Le chiavi prodotte da
transform.writesono già committate quandoonChangeparte: puoi usarle come stato di partenza per ulteriori derivazioni.
2. Validazione e dirty tracking sui campi modificati da patch
Ogni chiave/valore passato a patch(...) transita dallo stesso percorso di commit di un campo normale (fieldCommit). Conseguenze automatiche, senza codice aggiuntivo:
- Validazione live rivalutata sui campi toccati: se una patch imposta
riepilogo: ''su un campo convalidation: 'required', il form passa avalid: falseimmediatamente e il pulsante save si disabilita. - Dirty tracking aggiornato: i campi modificati dalla patch finiscono in
dirtyValuescome se l'utente li avesse digitati. - Re-render reattivo dei campi dipendenti via
visible/disabled/helpche osservano quelle chiavi.
isEdit: una patch che azzera un required mostra subito l'errore on-blur. Se vuoi un prefill silenzioso, assicurati che la patch produca sempre valori validi (o passa attraverso valori intermedi accettabili).3. Niente ricorsione fra onChange e patch
patch(...) invoca solo il commit del campo target — non rilancia l'onChange di quel campo. Questo è intenzionale e previene cicli accidentali del tipo onChange-A → patch(B) → onChange-B → patch(A) → ....
Conseguenza pratica: se hai una catena di derivazioni A → B → C (cambio A prefilla B; cambio B prefilla C), non puoi delegarla mettendo un onChange sia su A che su B. Devi:
- Centralizzare tutta la logica derivata nell'
onChangedel campo sorgente (A), che calcola da solo i valori di B + C e li applica in un'unica patch, oppure - Promuovere a logica globale ascoltando l'evento
@field-changesuDynamicFormLayouts/DynamicFormDialog, dove sei tu a decidere quando reagire.
// OK — un solo onChange su A che deriva B + C
{
name: 'idtipo',
type: 'select',
onChange: (newId, _data, patch) => {
const tipo = tipi.value.find(t => t.id === newId)
if (!tipo) return
patch({
riepilogo: tipo.riepilogo, // B
assegnatari: tipo.assegnatari, // C derivato dallo stesso tipo
durataMinuti: tipo.durataDefault, // anche D, se serve
})
},
}
// NON funziona — cambiare B via patch NON innesca l'onChange di B
{
name: 'riepilogo',
type: 'text',
onChange: (value, _data, patch) => {
// questo NON parte quando idtipo prefilla riepilogo via patch
patch({ slug: slugify(String(value)) })
},
}
Per il secondo caso (derivazione che deve scattare anche quando il campo è modificato programmaticamente), usa un listener globale @field-change nel parent — vedi tabella onChange vs field-change sopra.
Help reattivo
field.help accetta una stringa statica oppure una funzione (value, formData) => string per messaggi di aiuto reattivi che si aggiornano al cambio dei valori — utile per contatori caratteri, riepiloghi calcolati, indicazioni dinamiche basate su altri campi.
{
name: 'descrizione',
type: 'textarea',
label: 'Descrizione',
validation: 'max:125',
// Contatore caratteri reattivo
help: (value) => `${String(value ?? '').length}/125 caratteri`,
}
{
name: 'sconto',
type: 'number',
label: 'Sconto %',
// Riepilogo calcolato su altri campi
help: (value, data) => {
const totale = Number(data.importoTotale ?? 0)
const percentuale = Number(value ?? 0)
const scontato = totale * (1 - percentuale / 100)
return `Importo finale: € ${scontato.toFixed(2)}`
},
}
La funzione viene rivalutata ad ogni cambiamento di value o formData (deep watch).
Campi virtuali (field.transform)
field.transform è un bridge tra il valore UI di un campo e una o più proprietà del formData. Permette di mostrare un singolo widget (es. DateTimePicker) che internamente legge/scrive su campi separati (data + ora), oppure di applicare proiezioni/aggregazioni senza modificare lo schema dei dati persistiti.
interface FieldDefinition {
// ...
transform?: {
/** Calcola il valore UI dai dati del form */
read: (formData: Record<string, unknown>) => unknown
/** Calcola la patch del formData a partire dal nuovo valore UI */
write: (uiValue: unknown, formData: Record<string, unknown>) =>
Partial<Record<string, unknown>>
}
}
Esempio: DateTimePicker su due campi separati
Backend espone datascadenza (date) + orarioinizio (time). UI vuole un singolo DateTimePicker che combini i due:
{
name: '_datetimeinizio', // nome "virtuale" (sottolineato per convenzione)
type: 'date',
label: 'Data e ora inizio',
exclude: true, // non includere il campo virtuale nel submit
props: { showTime: true },
transform: {
read: (data) => {
if (!data.datascadenza || !data.orarioinizio) return null
return `${data.datascadenza}T${data.orarioinizio}`
},
write: (val) => {
if (!val) return { datascadenza: null, orarioinizio: null }
const [date, time] = String(val).split('T')
return { datascadenza: date, orarioinizio: time }
},
},
}
- Il campo è tipicamente "virtuale" — combina con
exclude: trueper evitare chefield.namefinisca nel submit. - La validazione (es.
required, regex) viene applicata al risultato diread(formData), non ai campi sottostanti del backend. writeritorna una patch parziale delformData— solo le chiavi modificate vengono aggiornate.
Esempio: somma calcolata read-only
{
name: '_totale',
type: 'number',
label: 'Totale calcolato',
readonly: true,
exclude: true,
transform: {
read: (data) => Number(data.imponibile ?? 0) + Number(data.iva ?? 0),
write: () => ({}), // read-only: nessuna scrittura
},
}
Submit nativo (withSubmit)
Quando withSubmit: true il form viene wrappato in <form> con un handler @submit. L'evento submit riceve i valori validi (tutti i campi non exclude).
Disponibile solo con
displayMode='normal'.
<!-- Codice reale con API -->
<script setup lang="ts">
const formData = ref({})
async function handleSubmit(values) {
await api.create(values)
}
</script>
<template>
<DynamicFormLayouts
v-model="formData"
:schema="schema"
with-submit
@submit="handleSubmit"
/>
</template>
Propagazione disabled/readonly
Le prop top-level disabled e readonly vengono fornite ai discendenti via provide('dynamicFormDisabled', ref) e provide('dynamicFormReadonly', ref). Tutti i wrapper di @pzeta/vue-form (e i componenti custom che usano inject) le rispettano automaticamente, sovrascrivendo l'eventuale valore di campo.
<DynamicFormLayouts
v-model="formData"
:schema="schema"
:disabled="isSaving"
:readonly="!canEdit"
/>
Inject keys per componenti custom
Componenti custom posizionati nel sotto-albero possono leggere lo stato globale del form tramite inject:
<script setup lang="ts">
import { inject, type ComputedRef } from 'vue'
const dynamicFormDisabled = inject<ComputedRef<boolean>>('dynamicFormDisabled')
const dynamicFormReadonly = inject<ComputedRef<boolean>>('dynamicFormReadonly')
// Usa i valori per disabilitare/render diverso
const isDisabled = computed(() =>
props.disabled || dynamicFormDisabled?.value || false
)
</script>
| Inject key | Tipo | Descrizione |
|---|---|---|
'dynamicFormDisabled' | ComputedRef<boolean> | True se il form è in stato disabled (somma di props.disabled + inheritedDisabled) |
'dynamicFormReadonly' | ComputedRef<boolean> | True se il form è in stato readonly |
ComputedRef (non plain ref): i componenti che le iniettano devono accedere al valore via .value. Se il componente non è discendente di DynamicFormLayouts, l'inject ritorna undefined (il check ?.value è obbligatorio).Note tecniche
inheritAttrs: false: il componente non auto-applica$attrsal root. Attributi comeclass,id, eventualidata-*vengono applicati al wrapper interno della modalità corrente (form, div, Tabs, Stepper, Accordion) tramitev-bind="$attrs". Questo permette di stilare il form esterno senza ereditareclasssu elementi interni inappropriati.- Re-init automatico al cambio schema:
watchsuprops.schema(no deep) chiamainitializeForm()e resettaactiveStepa'0'. Per modifiche in-place dello schema (mutazioni interne) chiama manualmente il metodo espostoinitializeForm(). - Loop prevention
isExternalUpdate: il componente usa un flag interno per evitare loop tra il watcher suprops.modelValuee quello suformDataquando i valori vengono sincronizzati esternamente.
Eventi
| Evento | Payload | Descrizione |
|---|---|---|
update:modelValue | Record<string, unknown> | Aggiornamento valori (v-model) |
update:dirty | boolean | Cambio stato dirty |
update:dirty-values | Record<string, unknown> | Aggiornamento campi modificati |
update:valid | boolean | Cambio stato validità globale |
update:active-step | string | Cambio tab/step attivo |
field-change | (fieldName: string, value: unknown, formData: Record<string, unknown>) | Emesso dopo il commit di un campo. Utile per logiche globali (telemetria, sync esterni). Per logiche locali al campo preferire l'hook field.onChange |
button-click | (fieldName, action, value) | Click su campo di tipo button |
submit | Record<string, unknown> | Submit nativo (solo se withSubmit=true) |
Slot
| Slot | Slot props | Quando attivo |
|---|---|---|
field-{name}-{childSlot} | propagati dal componente input sottostante | Forwarda lo slot {childSlot} al componente del campo {name}. Es. field-tipoId-option espone l'option di Select per il campo tipoId |
section-before-{idx} | { values, valid, dirty, isEdit, submitting, sectionIndex } | Inserisce contenuto prima della sezione di indice {idx} (0-based). Disponibile in tutte le modalità (normal, tabs, wizard, accordion) |
section-after-{idx} | { values, valid, dirty, isEdit, submitting, sectionIndex } | Inserisce contenuto dopo la sezione di indice {idx} (0-based). Disponibile in tutte le modalità |
actions | { validate, reset, values } | Solo con withSubmit: true — pulsantiera in fondo al <form> con accesso a metodi e valori correnti |
Slot field-{name}-{childSlot} per personalizzare il rendering interno di un campo
Il FormSectionRow interno mappa ogni slot del pattern field-{fieldName}-{childSlotName} allo slot {childSlotName} del componente del campo {fieldName} (componentMap[type]). Questo permette di personalizzare le parti interne dei componenti (es. il template di un'opzione Select, il pannello vuoto di un MultiSelect, l'header di un Tree) senza dover sostituire l'intero campo.
<DynamicFormLayouts v-model="formData" :schema="schema">
<!-- Espone lo slot `option` del Select del campo `tipoId` -->
<template #field-tipoId-option="{ option }">
<span class="flex items-center gap-2">
<i :class="option.icona" :style="{ color: option.colore }" />
{{ option.nome }}
</span>
</template>
<!-- Espone lo slot `value` del MultiSelect del campo `tags` -->
<template #field-tags-value="{ value }">
<Chip v-for="tag in value" :key="tag" :label="tag" />
</template>
</DynamicFormLayouts>
field-{name} (senza child slot, come #field-rating) non sovrascrive il rendering del campo: i campi sono renderizzati esclusivamente dalla componentMap in base a field.type. Per cambiare il componente di un campo si usa field.type o si registra un tipo custom nella componentMap. Per personalizzare il rendering interno del componente, usa il pattern field-{name}-{childSlot}.Slot section-before-{idx} / section-after-{idx} per panel data-aware
Lo scope è ricalcolato ad ogni cambio di formData/valid/dirty, quindi il contenuto si aggiorna in tempo reale rispetto allo stato del form:
<DynamicFormLayouts v-model="formData" :schema="schema">
<!-- Avviso prima della prima sezione, basato sui dati correnti -->
<template #section-before-0="{ values, valid }">
<div v-if="!valid" class="bg-amber-50 border border-amber-200 p-3 rounded mb-4">
<i class="pi pi-exclamation-triangle text-amber-600" />
Compila tutti i campi obbligatori per procedere
</div>
</template>
<!-- Riepilogo dopo la seconda sezione (indice 1) -->
<template #section-after-1="{ values, isEdit, submitting }">
<div class="text-sm text-gray-500 mt-2">
<span v-if="isEdit">Stai modificando il record #{{ values.id }}</span>
<Button
v-if="!isEdit"
label="Anteprima"
icon="pi pi-eye"
text
:disabled="submitting"
@click="showPreview = true"
/>
</div>
</template>
</DynamicFormLayouts>
normal+submit, normal, tabs, wizard, accordion), quindi funzionano anche dentro tab/step/accordion panel. La slot prop sectionIndex permette di distinguere se necessario quando lo stesso slot ha logica condivisa.Slot actions per pulsantiera con submit nativo
<DynamicFormLayouts
v-model="formData"
:schema="schema"
with-submit
@submit="onSubmit"
>
<template #actions="{ validate, reset, values }">
<div class="flex gap-2 justify-end mt-4">
<Button label="Reset" severity="secondary" @click="reset" />
<Button type="submit" label="Salva" />
</div>
</template>
</DynamicFormLayouts>
Disponibile solo con withSubmit: true e displayMode='normal'. I valori passati come slot props (validate/reset/values) sono i metodi esposti dal componente.
Metodi esposti (defineExpose)
Accessibili tramite un template ref:
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicFormLayouts } from '@pzeta/vue-form'
const formRef = ref<InstanceType<typeof DynamicFormLayouts> | null>(null)
async function handleSave() {
// Forza la validazione completa di tutti i campi
const isValid = formRef.value?.validate()
if (!isValid) return
// Accedi ai valori correnti (incluso campi exclude)
await api.save(formRef.value?.values)
}
function handleReset() {
formRef.value?.reset()
}
</script>
<template>
<DynamicFormLayouts ref="formRef" v-model="formData" :schema="schema" />
<button @click="handleSave">Salva</button>
<button @click="handleReset">Reset</button>
</template>
| Metodo / proprietà | Tipo | Descrizione |
|---|---|---|
validate() | () => boolean | Valida tutti i FormSectionRow montati. Ritorna true se tutti i campi sono validi |
reset() | () => void | Resetta formData ai valori iniziali (initialData + defaultValue dei campi) |
initializeForm() | () => void | Re-inizializza il form dallo schema corrente. Chiamato automaticamente al cambio di reference di schema. Resetta anche visitedWizardSteps (in isEdit: true marca tutti gli step come visitati, altrimenti solo lo step 0) |
values | Ref<Record<string, unknown>> | Riferimento reattivo ai valori interni (incluso campi exclude) |
wizardIsFirstStep | ComputedRef<boolean> | true se lo step corrente è il primo step visibile del wizard (le sezioni nascoste con visible: false non contano). Da usare per disabilitare/nascondere il pulsante "Indietro" |
wizardIsLastStep | ComputedRef<boolean> | true se lo step corrente è l'ultimo step visibile del wizard. Da usare per sostituire "Avanti" con il pulsante di submit del form |
wizardCanAdvance | ComputedRef<boolean> | true se la sezione corrente è valida e l'utente può quindi avanzare allo step successivo. Da usare per disabilitare il pulsante "Avanti" |
wizardGoPrev() | () => void | Naviga allo step visibile precedente (no-op se wizardIsFirstStep è true). Marca anche lo step di destinazione come "visitato" |
wizardGoNext() | () => void | Naviga allo step visibile successivo (no-op se la sezione corrente non è valida o se wizardIsLastStep è true). Marca lo step di destinazione come "visitato", sbloccandolo nella StepList in wizardLinear mode |
validate() chiama validateAll su ciascun FormSectionRow figlio: a differenza della validazione on-blur (che valida un campo alla volta), forza la validazione completa — utile per pulsanti di submit custom che vogliono evidenziare tutti i campi mancanti in un colpo solo.wizard* sono pensati per consumer wrapper (es. DynamicFormDialog) che renderizzano i pulsanti "Indietro/Avanti" nel proprio footer invece che dentro il body del wizard. La navigazione tramite click sulle testate <Step> è già gestita internamente dallo Stepper — questi helper servono solo se vuoi pulsanti di navigazione esterni. Lo Stepper continua a sincronizzarsi con v-model:active-step indipendentemente dall'uso degli helper.Vedere anche
FormSection— sezione con header, layoutvertical/horizontal/gride comportamento collassabile (usato internamente, esportato per layout custom)FormSectionRow— singola riga di campi (componente interno)- Schema builders — helper fluenti
field/row/section/form, alternativa opzionale all'oggetto literal (che resta la forma raccomandata)
DynamicFormDrawerWithTable
Drawer laterale con DataTable integrata e form CRUD completo per gestione record tramite dialog.
DynamicFormListDialog
Dialog che combina lista di record esistenti + form di aggiunta inline. Gestione collezioni embedded senza data fetching — il parent passa items e gestisce add/remove via eventi.