-
Notifications
You must be signed in to change notification settings - Fork 0
Component FormShell
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.
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<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>) => FormFieldDef<T>[], // 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.
// 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()}`
);| 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 |
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 | 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.
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)
color?: ButtonColor; // colore del bottone (default: 'tertiary')
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)
}| 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 mancalabel,'text'selabelè presente.
| Valore | Sorgente colore |
|---|---|
'primary' |
Token Material --color-primary / --color-on-primary
|
'secondary' |
Token Material --color-secondary / --color-on-secondary
|
'tertiary' |
Token Material --color-tertiary / --color-on-tertiary (default)
|
'error' |
Token Material --color-error / --color-on-error
|
'success' |
Tailwind green-600 / white
|
'info' |
Tailwind sky-600 / white
|
'warn' |
Tailwind amber-500 / black
|
I colori Material si adattano automaticamente al tema attivo (gpa, cobalt, forest). I colori semantici sono fissi.
| Valore | Rendering |
|---|---|
'inline' |
Stessa riga in fondo alla griglia (default) |
'footer' |
Riga dedicata sotto la riga inline |
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 con colore primario (submit)
{
label: 'Salva', icon: 'save', variant: 'filled', color: 'primary',
disabled: () => someSignal(),
onClick: () => this.formModel.submit(() => this.save()),
},
// Azione su riga dedicata con colore error
{
label: 'Elimina', icon: 'delete', variant: 'text', color: 'error', position: 'footer',
onClick: () => this.delete(),
},
// Azione visibile solo in edit mode (signal-based)
{
icon: 'delete', tooltip: 'Elimina',
visible: () => this.isEditMode(),
onClick: () => this.delete(),
},
];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.
| 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) |
FormFieldDefè solo layout UI. Validazione, visibilità e disabilitazione si dichiarano nello schema delFormModel.Usando
field: ft.nomeCampoinvece di una stringakey, TypeScript cattura i typo a compile time.
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 },
],
)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: 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, ...) |
{ field: ft.note, label: 'Note', component: TextareaFieldComponent, span: 2,
inputs: { rows: 4 } }| Input extra | Default | |
|---|---|---|
rows |
3 |
Numero righe visibili |
{ 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 }
|
{ field: ft.nascita, label: 'Data di nascita', component: DatepickerFieldComponent }Nessun input extra. Il valore nel modello è un oggetto Date.
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') |
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 ilFieldTree -
formField()()— chiama ilFieldTree→ restituisceFieldStatecon.value,.errors(),.touched(),.invalid()
Sviluppato e mantenuto da GPA — Gruppo Progetti Avanzati s.r.l. · gpa@gpagroup.it · +39 065913642 · P.IVA 05049391005 · ISO 9001 · ISO 27001
Sede legale: Via Ferdinando Galiani, 68 — 00191 Roma | Sedi operative: Piazzale Asia 21, Roma · Via Badoero 67, Roma · Viale Chiavellati 9, Foligno (PG)
npm · GitHub · Segnala un problema · Licenza MIT