Skip to content

Component FormShell

simone.tiberti edited this page Apr 24, 2026 · 7 revisions

FormShell (core-form-shell)

Sistema dichiarativo per form basato su Angular Signal Forms (@angular/forms/signals). Si dichiara il modello, i validatori e il layout UI in un unico FormModel — la shell gestisce la griglia responsive, la validazione e le azioni.


Utilizzo base

import {
  FormShellComponent,
  FormModel,
  TextInputFieldComponent,
  ComboboxFieldComponent,
} from '@gpa-gruppo-progetti-avanzati-srl/ng-core-ui';
import { required } from '@angular/forms/signals';

@Component({
  standalone: true,
  imports: [FormShellComponent],
  ...
})
export class PersonaEditComponent {

  protected readonly formModel = new FormModel(
    { nome: '', cognome: '', categoria: null as string | null },
    p => {
      required(p.nome,    { message: 'Nome obbligatorio' });
      required(p.cognome, { message: 'Cognome obbligatorio' });
    },
    ft => [
      { field: ft.nome,      label: 'Nome',      component: TextInputFieldComponent },
      { field: ft.cognome,   label: 'Cognome',   component: TextInputFieldComponent },
      { field: ft.categoria, label: 'Categoria', component: ComboboxFieldComponent,
        inputs: { options: [{ value: 'A', label: 'Tipo A' }, { value: 'B', label: 'Tipo B' }] } },
    ],
  );

  protected readonly actions: FormShellAction[] = [
    {
      label: 'Salva', icon: 'save', variant: 'filled',
      onClick: () => this.formModel.submit(() => this.save()),
    },
    {
      label: 'Annulla', variant: 'text',
      onClick: () => this.goBack(),
    },
  ];

  private save(): void {
    const values = this.formModel.model(); // { nome: 'Mario', cognome: 'Rossi', categoria: 'A' }
    console.log(values);
  }

  private goBack(): void { /* navigate away */ }
}
<core-form-shell
  [model]="formModel"
  [columns]="2"
  [actions]="actions"
/>

FormModel — costruzione

FormModel<T> è una classe che racchiude la signal form Angular e il layout UI.

new FormModel<T>(
  initialValue: T,                              // oggetto con i valori iniziali
  schema,                                       // funzione schema (required, min, max, ...)
  layout: (ft: FieldTree<T>) => FormFieldUIDef[], // factory che riceve ft e restituisce i campi
)

Il terzo argomento è una factory function che riceve il FieldTree<T> tipizzato e restituisce la definizione UI dei campi. Grazie alla tipizzazione su T, TypeScript cattura a compile time qualsiasi typo nei nomi dei campi.

Accesso ai valori

// Intero oggetto (al submit o in qualsiasi momento)
const values = this.formModel.model();   // T — tutti i valori correnti

// Un campo reattivo in computed()
readonly preview = computed(() =>
  `Ciao ${this.formModel.ft.nome().value()}`
);

Metodi di utility

Metodo Descrizione
submit(action: () => void) Marca tutti i campi come touched, verifica invalid(), esegue action() solo se valido
markAllAsTouched() Marca tutti i campi come touched (forza la visualizzazione degli errori)
reset(value?: T) Resetta lo stato touched/dirty di tutti i campi; opzionalmente aggiorna il valore
invalid Signal<boolean>true se almeno un campo è invalido

Edit mode (valori iniziali)

protected readonly formModel = new FormModel(
  this.persona,   // { nome: 'Mario', cognome: 'Rossi', ... }
  p => { required(p.nome); required(p.cognome); },
  ft => [
    { field: ft.nome,    label: 'Nome',    component: TextInputFieldComponent },
    { field: ft.cognome, label: 'Cognome', component: TextInputFieldComponent },
  ],
);

Input della shell

Input Tipo Default Descrizione
model FormModel Il form model (obbligatorio)
columns number 2 Colonne della griglia
actions FormShellAction[] [] Bottoni azione della form

La shell non emette output. Tutte le azioni si gestiscono tramite actions.


FormShellAction — bottoni azione

export interface FormShellAction {
  icon?:     string;           // nome icona Material
  label?:    string;           // testo bottone
  tooltip?:  string;           // tooltip al hover
  variant?:  'icon' | 'text' | 'filled'; // stile (default: 'icon' se no label, 'text' se label)
  position?: 'inline' | 'footer';        // posizione (default: 'inline')
  onClick:   () => void;
  visible?:  () => boolean;    // funzione reattiva per nascondere/mostrare (default: true)
  disabled?: () => boolean;    // funzione reattiva per disabilitare (default: false)
}

variant

Valore Rendering Quando usarlo
'icon' mat-icon-button (solo icona, sfondo vuoto) Azioni secondarie compatte
'text' mat-button (testo, sfondo vuoto) Azioni secondarie con label
'filled' mat-flat-button (sfondo pieno) Azione principale (submit)

Se variant è omesso: 'icon' se manca label, 'text' se label è presente.

position

Valore Rendering
'inline' Stessa riga in fondo alla griglia (default)
'footer' Riga dedicata sotto la riga inline

Esempio con azioni miste

protected readonly actions: FormShellAction[] = [
  // Azione inline icon-only
  { icon: 'attachment', tooltip: 'Allega file', onClick: () => this.openAttach() },
  // Azione inline text
  { label: 'Annulla', variant: 'text', onClick: () => this.goBack() },
  // Azione inline filled (submit)
  {
    label: 'Salva', icon: 'save', variant: 'filled',
    disabled: () => someSignal(),
    onClick: () => this.formModel.submit(() => this.save()),
  },
  // Azione su riga dedicata
  {
    label: 'Elimina', icon: 'delete', variant: 'text', position: 'footer',
    onClick: () => this.delete(),
  },
  // Azione visibile solo in edit mode (signal-based)
  {
    icon: 'delete', tooltip: 'Elimina',
    visible:  () => this.isEditMode(),
    onClick:  () => this.delete(),
  },
];

Validazione

I validatori si dichiarano nello schema (secondo argomento di FormModel):

import { required, min, max, minLength, email } from '@angular/forms/signals';

new FormModel(
  { nome: '', eta: 0, email: '', codice: '' },
  p => {
    required(p.nome,       { message: 'Nome obbligatorio' });
    min(p.eta, 18,         { message: 'Età minima 18 anni' });
    max(p.eta, 120,        { message: 'Età massima 120 anni' });
    email(p.email,         { message: 'Email non valida' });
    minLength(p.codice, 3, { message: 'Almeno 3 caratteri' });
  },
  ft => [ ... ],
)

Gli errori vengono mostrati nei campi solo dopo che sono stati toccati. formModel.submit(action) chiama markAllAsTouched() automaticamente prima di validare.


FormFieldUIDef — proprietà

Proprietà Tipo Descrizione
field FieldTree<any> Riferimento diretto al nodo del form (es. ft.nome) — type-safe
label string Etichetta visibile
component Type<CoreFieldComponent> Componente da renderizzare
span number? Colonne occupate nella griglia (default: 1, max: columns)
inputs Record<string, any>? Input aggiuntivi passati al componente (es. options)

FormFieldUIDef è solo layout UI. Validazione, visibilità e disabilitazione si dichiarano nello schema del FormModel.

Usando field: ft.nomeCampo invece di key: 'nomeCampo', TypeScript cattura i typo a compile time.


Hidden e Disabled — via schema

Visibilità e disabilitazione si dichiarano nello schema del FormModel con hidden() e disabled() di @angular/forms/signals. La shell legge FieldState.hidden() e FieldState.disabled() direttamente — nessuna callback nel layout.

import { required, hidden, disabled } from '@angular/forms/signals';

new FormModel(
  { tipo: null as string | null, piva: '', cf: '', note: '' },
  p => {
    required(p.tipo, { message: 'Tipo obbligatorio' });
    hidden(p.piva,  () => p.tipo().value() !== 'azienda');
    hidden(p.cf,    () => p.tipo().value() !== 'persona');
    disabled(p.note, () => !p.tipo().value());
  },
  ft => [
    { field: ft.tipo,  label: 'Tipo',  component: ComboboxFieldComponent,
      inputs: { options: [{ value: 'azienda', label: 'Azienda' }, { value: 'persona', label: 'Persona' }] } },
    { field: ft.piva,  label: 'P.IVA', component: TextInputFieldComponent },
    { field: ft.cf,    label: 'C.F.',  component: TextInputFieldComponent },
    { field: ft.note,  label: 'Note',  component: TextareaFieldComponent, span: 2 },
  ],
)

Grid responsive

columns Uso tipico
1 Mobile, form semplici
2 Default, form standard
3 Form articolati, schermi larghi
4 Dashboard, form molto ricchi

Ogni campo occupa span colonne (default 1). Se span > columns viene cappato automaticamente.


Field component built-in

TextInputFieldComponent

{ field: ft.email, label: 'Email', component: TextInputFieldComponent,
  inputs: { type: 'email' } }
{ field: ft.eta,   label: 'Età',   component: TextInputFieldComponent,
  inputs: { type: 'number' } }
Input extra Default
type 'text' Tipo HTML dell'input (text, email, number, password, ...)

TextareaFieldComponent

{ field: ft.note, label: 'Note', component: TextareaFieldComponent, span: 2,
  inputs: { rows: 4 } }
Input extra Default
rows 3 Numero righe visibili

ComboboxFieldComponent

{ field: ft.stato, label: 'Stato', component: ComboboxFieldComponent,
  inputs: {
    options: [
      { value: 'attivo',  label: 'Attivo' },
      { value: 'sospeso', label: 'Sospeso' },
    ]
  }
}
Input extra Tipo
options ComboboxOption[] Array di { value, label }

DatepickerFieldComponent

{ field: ft.nascita, label: 'Data di nascita', component: DatepickerFieldComponent }

Nessun input extra. Il valore nel modello è un oggetto Date.


LookupFieldComponent

Apre una dialog custom implementata nell'app e riceve un { id, label }.

import { LookupFieldComponent, LookupResult } from '@gpa-gruppo-progetti-avanzati-srl/ng-core-ui';

{ field: ft.persona, label: 'Persona', component: LookupFieldComponent,
  inputs: {
    dialogConfig: {
      component: PersonaPickerDialog,
      width:     '800px',
      data:      { tipo: 'fisica' },
    }
  },
}

La validazione si dichiara nello schema:

required(p.persona, { message: 'Selezionare una persona' });

Il valore è LookupResult | null:

onClick: () => this.formModel.submit(() => {
  const persona = this.formModel.model().persona as LookupResult;
  console.log(persona.id, persona.label);
})

Contratto del dialog:

@Component({ ... })
export class PersonaPickerDialog {
  private ref = inject(MatDialogRef<PersonaPickerDialog>);
  readonly data = inject(MAT_DIALOG_DATA);

  select(p: Persona): void {
    this.ref.close({ id: p.id, label: `${p.nome} ${p.cognome}` });
  }

  cancel(): void {
    this.ref.close(null);
  }
}
Input extra Tipo
dialogConfig.component Type<any> Dialog da aprire (obbligatorio)
dialogConfig.data Record<string, any>? Dati passati via MAT_DIALOG_DATA
dialogConfig.width string? Larghezza dialog (default: '600px')
dialogConfig.maxWidth string? Larghezza massima (default: '95vw')

Campo custom

Qualsiasi componente che implementa il contratto CoreFieldComponent può essere usato:

@Component({
  standalone: true,
  template: `
    <label>{{ label() }}</label>
    <input
      type="range" min="0" max="100"
      [formField]="formField()"
    />
    @if (formField()().touched() && formField()().errors().length) {
      <p class="text-red-500 text-xs">{{ formField()().errors()[0]?.message }}</p>
    }
  `
})
export class SliderFieldComponent {
  readonly formField = input.required<any>();
  readonly label     = input<string>('');
}

Il pattern formField()():

  • formField() — chiama l'InputSignal → restituisce il FieldTree
  • formField()() — chiama il FieldTree → restituisce FieldState con .value, .errors(), .touched(), .invalid()

Vedi anche

  • DataTable — lista con paginazione e sort server-side
  • Confirm — conferma prima di azioni distruttive
  • Toast — notifiche dopo il salvataggio

Clone this wiki locally