Components

DynamicFormLayouts

Form dinamico con validazione on-blur, errori inline per campo, modalità tabs/wizard/accordion, condizioni dinamiche, esclusione campi e propagazione disabled/readonly via provide.

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:valid per integrazione con logica di salvataggio.
  • Cross-field validation: validation-rules per regole tra campi (match, different, requiredIf, ecc.) valutate al cambio di formData tramite validateCrossFields.
  • Condizioni dinamiche: disabled e visible su campo/sezione accettano boolean | FieldCondition | (data) => boolean (predicate function per logica complessa).
  • Esclusione campi dal submit: field.exclude = true + tracking automatico via excludedFieldsMap.
  • Propagazione globale disabled/readonly: i figli ricevono lo stato via inject('dynamicFormDisabled') / inject('dynamicFormReadonly').
  • Submit nativo opzionale: con withSubmit il form viene wrappato in <form @submit> ed emette submit con i valori validi (campi exclude filtrati).
  • Validazione al mount governata da isEdit: in modalità isEdit: true tutti i campi vengono validati al mounted (utile per evidenziare required vuoti su record legacy con dati incompleti); in modalità insert (isEdit: false) il mount è silenzioso — il pulsante save resta comunque disabled grazie a update:valid derivato live dal formData.
  • Wizard lineare con guard di avanzamento: in displayMode='wizard', wizardLinear (default true) blocca lo Stepper sugli step non ancora visitati e disabilita "Avanti" finché la sezione corrente non è valida. Le sezioni con visible: false vengono saltate automaticamente.
  • Metodi esposti via defineExpose: validate(), reset(), initializeForm(), values, e i wizard helper (wizardCanAdvance, wizardIsFirstStep, wizardIsLastStep, wizardGoPrev, wizardGoNext) accessibili da un template ref.
  • Re-init automatico al cambio schema: se la reference di props.schema cambia, il form viene ri-inizializzato e activeStep torna 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
Predicate function: oltre a 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: sectionsrowsfields. Le righe rendono i campi su una griglia con colonne uguali (grid-template-columns: repeat(N, minmax(0, 1fr))); con field.span puoi assegnare una larghezza diversa al singolo campo.

Props

PropTipoDefaultDescrizione
schemaFormSchemaSchema del form (sections > rows > fields)
modelValueRecord<string, unknown>{}Valori del form (v-model)
displayMode'normal' | 'tabs' | 'wizard' | 'accordion''normal'Modalità di visualizzazione
initialDataRecord<string, unknown>{}Dati iniziali per calcolo dirty
validationRulesCrossFieldValidationRule[][]Regole cross-field (match, different, ecc.)
dirtybooleanfalseForm modificato (v-model:dirty)
dirtyValuesRecord<string, unknown>{}Solo campi modificati (v-model:dirty-values)
validbooleantrueForm valido (v-model:valid)
activeStepstring'0'Step corrente per tabs/wizard (v-model:active-step)
disabledbooleanfalseDisabilita tutto il form (propagato ai figli via provide)
readonlybooleanfalseModalità sola lettura (propagato ai figli via provide)
withSubmitbooleanfalseWrappa il form in <form @submit> (solo displayMode='normal')
wizardLinearbooleantrueSolo 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
isEditbooleanfalseModalità 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
submittingbooleanfalseStato di submitting controllato dal parent. Esposto come slot prop di section-before-{idx} / section-after-{idx} per disabilitare contenuto custom durante un save async
devModebooleanfalseMostra info debug (sfondo evidenziato sulle sezioni)
isEdit: oltre a essere propagata negli slot scoped delle sezioni, modifica due comportamenti interni:
  • mount: in isEdit: true viene chiamato validate() al mounted (popola gli errori dei required vuoti per record legacy). In isEdit: false nessuna validazione iniziale.
  • blur: in isEdit: true la validazione on-blur dei singoli campi è attiva (UX per modifica). In isEdit: false il blur è silenzioso (UX per inserimento da zero — il pulsante save resta comunque disabled grazie a update:valid calcolato 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)

ValoreComportamento
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
falseNavigazione 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).

Modalità libera (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.

Per regole su un singolo campo (es. 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 statico
  • FieldCondition — 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',
}
La predicate riceve l'intero 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

PatternQuando 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)
Co-esistenza: 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 value ricevuto da onChange è il valore UI grezzo dell'input (lo stesso tipo che transform.read produrrebbe dal formData) — non i valori atomici espansi da transform.write. Se il campo è virtuale con un transform.write che esplode in più chiavi, leggi il risultato espanso direttamente da formData dentro onChange, non da value.
  • Le chiavi prodotte da transform.write sono già committate quando onChange parte: 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 con validation: 'required', il form passa a valid: false immediatamente e il pulsante save si disabilita.
  • Dirty tracking aggiornato: i campi modificati dalla patch finiscono in dirtyValues come se l'utente li avesse digitati.
  • Re-render reattivo dei campi dipendenti via visible / disabled / help che osservano quelle chiavi.
Attenzione in 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'onChange del 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-change su DynamicFormLayouts / 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 }
    },
  },
}
Note importanti:
  • Il campo è tipicamente "virtuale" — combina con exclude: true per evitare che field.name finisca nel submit.
  • La validazione (es. required, regex) viene applicata al risultato di read(formData), non ai campi sottostanti del backend.
  • write ritorna una patch parziale del formData — 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 keyTipoDescrizione
'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
Le inject sono 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 $attrs al root. Attributi come class, id, eventuali data-* vengono applicati al wrapper interno della modalità corrente (form, div, Tabs, Stepper, Accordion) tramite v-bind="$attrs". Questo permette di stilare il form esterno senza ereditare class su elementi interni inappropriati.
  • Re-init automatico al cambio schema: watch su props.schema (no deep) chiama initializeForm() e resetta activeStep a '0'. Per modifiche in-place dello schema (mutazioni interne) chiama manualmente il metodo esposto initializeForm().
  • Loop prevention isExternalUpdate: il componente usa un flag interno per evitare loop tra il watcher su props.modelValue e quello su formData quando i valori vengono sincronizzati esternamente.

Eventi

EventoPayloadDescrizione
update:modelValueRecord<string, unknown>Aggiornamento valori (v-model)
update:dirtybooleanCambio stato dirty
update:dirty-valuesRecord<string, unknown>Aggiornamento campi modificati
update:validbooleanCambio stato validità globale
update:active-stepstringCambio 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
submitRecord<string, unknown>Submit nativo (solo se withSubmit=true)

Slot

SlotSlot propsQuando attivo
field-{name}-{childSlot}propagati dal componente input sottostanteForwarda 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>
Lo slot 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>
Disponibilità in tutte le modalità: gli slot section-before/after sono renderizzati in tutti e 5 i punti di rendering (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àTipoDescrizione
validate()() => booleanValida tutti i FormSectionRow montati. Ritorna true se tutti i campi sono validi
reset()() => voidResetta formData ai valori iniziali (initialData + defaultValue dei campi)
initializeForm()() => voidRe-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)
valuesRef<Record<string, unknown>>Riferimento reattivo ai valori interni (incluso campi exclude)
wizardIsFirstStepComputedRef<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"
wizardIsLastStepComputedRef<boolean>true se lo step corrente è l'ultimo step visibile del wizard. Da usare per sostituire "Avanti" con il pulsante di submit del form
wizardCanAdvanceComputedRef<boolean>true se la sezione corrente è valida e l'utente può quindi avanzare allo step successivo. Da usare per disabilitare il pulsante "Avanti"
wizardGoPrev()() => voidNaviga allo step visibile precedente (no-op se wizardIsFirstStep è true). Marca anche lo step di destinazione come "visitato"
wizardGoNext()() => voidNaviga 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 helpers: i 5 metodi/refs 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, layout vertical/horizontal/grid e 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)