facebook

Blog

Resta aggiornato

Vediamo come generare dinamicamente un form in Angular partendo da un JSON di metadati
Form dinamiche in Angular? Si può!
mercoledì 06 Marzo 2019

Qualche tempo fa ho potuto lavorare su Raptor Framework, il nostro prodotto per la generazione dinamica di operazioni CRUD a partire da codice .Net.

Durante il porting da .Net Full Framework a .Net Core e Angular, mi sono occupato del refactoring della gestione delle validazioni delle form Angular, utilizzando ReactiveForm, sostituendo la gestione custom che era stata creata.

Di base, il nostro framework genera un JSON di metadati, utilizzato dal front-end Angular per la generazione dinamica della UI. Tale JSON viene generato a partire da Data Annotations custom, applicate ai ViewModel di una applicazione .NET. Eccone una versione semplificata:

meta-data

{
  "name": "DynamicForm",
  "displayName": "DynamicForm",
  "type": "object",
  "properties": {
    "Name": {
      "Required": {
        "AllowEmptyStrings": { "type": "System.Boolean", "value": false },
        "ErrorMessage": { "type": "System.String", "value": "Name is mandatory" },
        "ErrorMessageResourceName": {
          "type": "System.String",
          "value": "ErrorMessageResourceName"
        },
        "ErrorMessageResourceType": {
          "type": "System.Type",
          "value": "ErrorMessageResourceType"
        },
        "RequiresValidationContext": {
          "type": "System.Boolean",
          "value": false
        },
        "uiControl": false,
        "uiValidation": true,
        "type": "Raptor.Core.DataAnnotations.Validation.RequiredAttribute"
      },
      "StringLength": {
        "ErrorMessage": { "type": "System.String", "value": "Name length must be between 3 and 20 characters" },
        "ErrorMessageResourceName": {
          "type": "System.String",
          "value": "ErrorMessageResourceName"
        },
        "ErrorMessageResourceType": {
          "type": "System.Type",
          "value": "ErrorMessageResourceType"
        },
        "MaximumLength": { "type": "System.Int32", "value": 20 },
        "MinimumLength": { "type": "System.Int32", "value": 3 },
        "RequiresValidationContext": {
          "type": "System.Boolean",
          "value": false
        },
        "uiControl": false,
        "uiValidation": true,
        "type": "Raptor.Core.DataAnnotations.Validation.StringLengthAttribute"
      },
      "TextBox": {
        "ColLg": { "type": "System.Int32", "value": 6 },
        "ColMd": { "type": "System.Int32", "value": 6 },
        "ColSm": { "type": "System.Int32", "value": 6 },
        "ColXs": { "type": "System.Int32", "value": 6 },
        "EnabledWhenPropertyName": {
          "type": "System.String",
          "value": "EnabledWhenPropertyName"
        },
        "EnabledWhenPropertyValue": {
          "type": "System.Object",
          "value": "EnabledWhenPropertyValue"
        },
        "HiddenWhenCreate": { "type": "System.Boolean", "value": false },
        "HiddenWhenDelete": { "type": "System.Boolean", "value": false },
        "HiddenWhenEdit": { "type": "System.Boolean", "value": false },
        "Order": { "type": "System.Int32", "value": 0 },
        "ReadOnly": { "type": "System.Boolean", "value": false },
        "VisibleWhenPropertyName": {
          "type": "System.String",
          "value": "VisibleWhenPropertyName"
        },
        "VisibleWhenPropertyValue": {
          "type": "System.Object",
          "value": "VisibleWhenPropertyValue"
        },
        "uiControl": true,
        "uiValidation": false,
        "type": "Raptor.Core.DataAnnotations.Form.TextBoxAttribute"
      },
      "uiControlType": "textbox",
      "type": "System.String",
      "order": 0,
      "displayName": "Name",
      "placeholder": "NamePlaceHolder"
    },
    ...
  }
}

Una successiva chiamata, invece, ci fornirà i dati dell’entità di cui gestire il ciclo di vita:

{
  "Name": "Name",
  "Surname": "Surname",
  "BirthDate": null,
  "TermsOfService": false,
  "Id": 0
}

Il risultato dell’elaborazione lato client è il seguente:,

Nella demo, che potete trovare sul mio account GitHub, ho creato una versione semplificata di questo processo: ho sostituito, nella cartella assets, le chiamate HTTP che ricavano i JSON necessari, con dei file di esempio. In questo modo, possiamo concentrarci unicamente sulla parte di generazione dinamica dell’interfaccia.

FormComponent

Partiamo dal componente che rappresenta il contenitore della form che vogliamo generare, chiamato FormComponent:

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { forkJoin } from 'rxjs';
import { FormService } from '../services/form.service';
import { RaptorDetailModel } from './form.model';
 
@Component({
  selector: 'app-form',
  templateUrl: 'form.component.html'
})
export class FormComponent implements OnInit {
  public detailsForm: FormGroup;
  public detail: RaptorDetailModel;
 
  constructor(private service: FormService) { }
 
  ngOnInit() {
    this.detailsForm = new FormGroup({});
    this.loadData();
  }
 
  private loadData() {
    forkJoin(
      this.service.getMetadata(),
      this.service.getData()
    ).subscribe(result => {
      this.detail = {} as RaptorDetailModel;
      this.detail.viewModelSchema = result[0];
      this.detail.viewModelData = result[1];
      this.initDetail();
    });
  }
 
  private initDetail() {
    this.detail.viewModelDisplayName = this.detail.viewModelSchema['name'];
 
    this.detail.propertiesDescriptors = [];
    this.detail.viewModelProperties = this.detail.viewModelSchema['properties'];
    this.detail.viewModelPropertiesKeys = Object.keys(
      this.detail.viewModelSchema['properties']
    );
 
    this.detail.viewModelPropertiesKeys.forEach(propertyKey => {
      this.detail.propertiesDescriptors.push({
        key: propertyKey,
        schema: this.detail.viewModelProperties[propertyKey],
        viewModelData: this.detail.viewModelData,
        order: this.detail.viewModelProperties[propertyKey].order,
        dependencies: this.detail.viewModelDependencies
      });
    });
  }
 
  public submit() {
    if (this.detailsForm.valid) {
      alert(JSON.stringify(this.detailsForm.value));
    } else {
      Object.keys(this.detailsForm.controls).forEach(field => {
        const control = this.detailsForm.get(field);
        control.markAsTouched({ onlySelf: true });
      });
    }
  }
}

Come potete vedere dal codice, dopo la creazione del componente, viene chiamato il metodo loadData(), che si preoccupa di fare le due chiamate al backend: i metadati e i dati. Dato che abbiamo bisogno di entrambi per proseguire, dobbiamo aspettare che entrambe le chiamate abbiano ultimato il loro lavoro. Usiamo quindi l’operatore forkJoin proprio a tale scopo. Se tutto va bene, dividiamo le due risposte in due proprietà, viewModelSchema (i metadati) e viewModelData (i dati), e invochiamo il metodo privato initDetail(), per inizializzare le proprietà che conterranno tutte le informazioni che ci servono.

Le informazioni di cui abbiamo bisogno sono definite nell’interfaccia RaptorDetailForm:

  • viewModelDisplayName, che conterrà il nome dell’entità da editare;
  • propertiesDescriptors, che conterrà i metadati su ogni proprietà della form;
  • viewModelProperties, che conterrà i dati di ogni proprietà della form;
  • viewModelPropertiesKeys, che conterrà le chiavi di ogni proprietà della form.

Questi valori ci semplificheranno la generazione dell’interfaccia. Il metodo submit(), come potete immaginare, ci permetterà di gestire l’invio della form al server con i dati editati dall’utente.

In questa demo, simuliamo l’invio dei dati con una alert(), ma è interessante guardare il ramo else, per analizzare come visualizzare gli errori di validazione al click del pulsante. Il metodo markAsTouched() di AbstractControl (la classe base dei controlli form di Angular) fa esattamente quello che dice: ‘tocca’ il controllo senza modificarne il valore, in modo da scatenarne l’aggiornamento, che normalmente avverrebbe con gli eventi focus e blur. Di fatto, stiamo forzando la validazione della form in caso di fallimento.

Il markup del componente è molto semplice:

<div *ngIf="detail">
  <h3>{{detail.viewModelDisplayName}}</h3>
  <form class="form-horizontal" novalidate
    [formGroup]="detailsForm">
    <div class="row">
      <app-control-container
        [detailsForm]="detailsForm"
        *ngFor="let property of detail.propertiesDescriptors"
        [viewModelProperty]="property">
      </app-control-container>
    </div>
    <div class="row mt-5">
        <button type="button"
          class="btn btn-primary col-12"
          scrollToInvalidField (click)="submit()">
          OK
        </button>
    </div>
  </form>
</div>

Come potete vedere, c’è un titolo, una form, un pulsante per l’invocazione del metodo submit() e un ciclo for per la generazione dei singoli controlli, implementata nel componente app-control-container.

ControlContainer

Il componente ControlContainer si occupa di wrappare gli effettivi controlli che compongono la form, partendo dai metadati ricevuti dal server. Quello che facciamo in questo controllo, è quindi selezionare il componente da renderizzare, creare l’AbstractControl da aggiungere al FormGroup e applicare le validazioni richieste. Aggiungeremo anche delle classi, sempre selezionate dai metadati, che permetteranno l’impaginazione del componente, sulla base del grid system di Bootstrap.

@Component({
  selector: 'app-control-container',
  templateUrl: 'control-container.component.html'
})
export class ControlContainerComponent implements OnInit {
 
  @Input() detailsForm: FormGroup;
  @Input() formControl: AbstractControl;
  @Input() viewModelProperty: RaptorPropertyDescriptorModel;
 
  @HostBinding('class') controlClass = '';
 
  ngOnInit() {
    this.formControl = new FormControl(this.viewModelProperty.viewModelData[this.viewModelProperty.key]);
    this.detailsForm.addControl(this.viewModelProperty.key, this.formControl);
    this.addValidationsToFormControl();
    this.addClassesToControl();
  }
}

Nel template, un semplice ngSwitch permette di scegliere il controllo da visualizzare passando in input la form e i metadati associati. Inoltre, aggiungiamo una label invisibile per ogni tipo di validazione prevista dal campo che mostreremo in caso di errore di validazione.

<ng-container [ngSwitch]="viewModelProperty.schema.uiControlType">
  <ng-container *ngSwitchCase="'textbox'">
      <app-textbox [detailsForm]="detailsForm" [viewModelProperty]="viewModelProperty">
      </app-textbox>
  </ng-container>
  <ng-container *ngSwitchCase="'checkbox'">
      <app-checkbox [detailsForm]="detailsForm" [viewModelProperty]="viewModelProperty">
      </app-checkbox>
  </ng-container>
  <ng-container *ngSwitchCase="'date'">
    <app-date [detailsForm]="detailsForm" [viewModelProperty]="viewModelProperty">
    </app-date>
  </ng-container>
</ng-container>
 
<ng-container *ngIf="this.detailsForm.get(this.viewModelProperty.key).touched || this.detailsForm.get(this.viewModelProperty.key).dirty">
  <ng-container *ngFor="let item of validationDictionary">
    <small
      class="form-text  text-danger"
      *ngIf="this.detailsForm.hasError(item.validation, this.viewModelProperty.key)" >
      {{validations[item.validationGlobal]?.errorMessage}}
    </small>
  </ng-container>
</ng-container>

Guardiamo nel dettaglio come vengono associate le validazioni e l’impostazione delle classi di Bootstrap per l’impaginazione.

Il nostro problema, qui, è mappare le validazioni che ci arrivano dai metadati, con i Validator di ReactiveForms di Angular. Ogni elemento della form può avere più di una validazione. Creiamo quindi un array di ValidatorFn, in cui inseriremo tutte le validazioni associate al campo. Il blocco for si occupa proprio di verificare che tra le proprietà di quel campo vi siano quelle definite nel VALIDATION_GLOBALS e, mediante il metodo extractValidatorsFromValidationName(), le converte nelle validazioni messe a disposizione dalla classe Validators. Alla fine del ciclo, settiamo le validazioni trovate attraverso il metodo setValidators() di AbstractControl.

control-container.component.ts

public validations: { [key: string]: RaptorValidationModel } = { };
public validationDictionary: { validation: string, validationGlobal: VALIDATION_GLOBALS }[] = [
  {validation: 'required', validationGlobal: VALIDATION_GLOBALS.REQUIRED },
  {validation: 'pattern', validationGlobal: VALIDATION_GLOBALS.REGULAR_EXPRESSION },
  {validation: 'maxlength', validationGlobal: VALIDATION_GLOBALS.STRING_LENGTH },
  {validation: 'minlength', validationGlobal: VALIDATION_GLOBALS.STRING_LENGTH },
  {validation: 'email', validationGlobal: VALIDATION_GLOBALS.EMAIL },
];
 
private addValidationsToFormControl() {
  const validators: ValidatorFn[] = [];
 
  for (const key in this.viewModelProperty.schema) {
    if (this.viewModelProperty.schema[key].hasOwnProperty(VALIDATION_GLOBALS.VALIDATION_PROPERTY)
            && this.viewModelProperty.schema[key][VALIDATION_GLOBALS.VALIDATION_PROPERTY] === true) {
 
      const validation = this.viewModelProperty.schema[key];
      this.validations[key] = {
        errorMessage: validation[VALIDATION_GLOBALS.ERROR_MESSAGE].value
      };
      this.extractValidatorsFromValidationName(validation, key, validators);
    }
  }
 
  this.detailsForm.controls[this.viewModelProperty.key].setValidators(validators);
}
 
private extractValidatorsFromValidationName(validation: any, key: string, validators: ValidatorFn[]) {
  switch (key) {
    case VALIDATION_GLOBALS.EMAIL:
      validators.push(Validators.email);
      break;
    case VALIDATION_GLOBALS.REGULAR_EXPRESSION:
      validators.push(Validators.pattern(validation[VALIDATION_GLOBALS.PATTERN].value));
      break;
    case VALIDATION_GLOBALS.REQUIRED:
      validators.push(Validators.required);
      break;
    case VALIDATION_GLOBALS.STRING_LENGTH:
      if (validation[VALIDATION_GLOBALS.MAXIMUM_LENGTH]) {
        validators.push(Validators.maxLength(validation[VALIDATION_GLOBALS.MAXIMUM_LENGTH].value));
      }
 
      if (validation[VALIDATION_GLOBALS.MINIMUM_LENGTH]) {
        validators.push(Validators.minLength(validation[VALIDATION_GLOBALS.MINIMUM_LENGTH].value));
      }
      break;
  }
}

Per impostare le classi, invece, eseguiamo un ciclo sulle proprietà dello schema, verifichiamo che appartengano effettivamente ad uiControl e che facciano parte delle possibili classi previste dal FORM_GLOBAL. In caso positivo, modifichiamo il nome della classe in modo tale da farla corrispondere a quella di Bootstrap, e la assegniamo all’attributo controlClass che è in HostBinding con class. HostBinding ci permette di proiettare questa proprietà sul tag del componente, cosa necessaria a Bootstrap per fare il suo lavoro (per maggiori dettagli date un occhio all’articolo di Michele: Componenti avanzati in Angular:come creare una dashboard).

control-container.component.ts

private addClassesToControl() {
  const dataAnnotations = Object.keys(this.viewModelProperty.schema);
 
  dataAnnotations.forEach(annotationKey => {
    const jsonAnnotation = this.viewModelProperty.schema[annotationKey];
 
    if (
      jsonAnnotation.hasOwnProperty(FORM_GLOBALS.UI_CONTROL) &&
      jsonAnnotation[FORM_GLOBALS.UI_CONTROL] === true
    ) {
      if (jsonAnnotation.hasOwnProperty(FORM_GLOBALS.COL_LG)) {
        this.addClass(jsonAnnotation, FORM_GLOBALS.COL_LG);
      }
      if (jsonAnnotation.hasOwnProperty(FORM_GLOBALS.COL_MD)) {
        this.addClass(jsonAnnotation, FORM_GLOBALS.COL_MD);
      }
      if (jsonAnnotation.hasOwnProperty(FORM_GLOBALS.COL_SM)) {
        this.addClass(jsonAnnotation, FORM_GLOBALS.COL_SM);
      }
      if (jsonAnnotation.hasOwnProperty(FORM_GLOBALS.COL_XS)) {
        this.addClass(jsonAnnotation, FORM_GLOBALS.COL_XS);
      }
    }
  });
}
 
private addClass(jsonAnnotation, jsonClass) {
  this.controlClass = this.controlClass + ' ' +
    this.humanizeClassStyleString(jsonClass, '-') +
      '-' +
      jsonAnnotation[jsonClass].value;
}
 
private humanizeClassStyleString(target, separator) {
  return target
    .replace(/([A-Z])/g, ' $1')
    .replace(/([A-Z])/g, function(str) {
      return str.toLowerCase();
    })
    .trim(' ')
    .replace(' ', separator);
}

Controlli

Non resta che definire i controlli. Nella demo, per semplicità, ne ho definiti solo tre: la checkbox, la textbox e il datepicker. L’obiettivo è rendere la creazione di questi controlli il più stupido possibile, concentrando la logica necessaria in un controllo base che chiameremo BaseControl:

import { Injectable, OnInit, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { ViewModelProperty } from './controls.model';
 
@Injectable()
export abstract class BaseControl implements OnInit {
  @Input() viewModelProperty: ViewModelProperty;
  @Input() detailsForm: FormGroup;
 
  public controlType: string;
  public controlKey: string;
  public controlLabel: string;
  public controlValue: any;
 
  ngOnInit() {
    this.controlType = this.viewModelProperty.schema.uiControlType;
    this.controlKey = this.viewModelProperty.key;
    this.controlLabel = this.viewModelProperty.schema['displayName'];
    this.controlValue = this.viewModelProperty.viewModelData[this.viewModelProperty.key];
  }
}

Come potete vedere, avendo in input tutte le informazioni che ci servono, ci basta dividerle in singole proprietà più, comode da utilizzare. A questo punto, i singoli componenti, che rappresentano i controlli dell’interfaccia, devono solo estendere questo componente.

Checkbox

import { Component } from '@angular/core';
import { BaseControl } from '../base-control';
 
@Component({
    selector: 'app-checkbox',
    templateUrl: 'checkbox.component.html'
})
export class CheckboxComponent
  extends BaseControl {
}
<div class="form-check mt-2 mt-sm-2 mb-2 mr-sm-2 mb-sm-0"
  [ngClass]="{'has-danger': !isValid}" [formGroup]="detailsForm">
  <label class="form-check-label">
    <input [formControlName]="controlKey" type="checkbox" class="form-check-input"> {{controlLabel}}
  </label>
</div>

Textbox

import { Component} from '@angular/core';
import { BaseControl } from '../base-control';
 
@Component({
    selector: 'app-textbox',
    templateUrl: 'textbox.component.html'
})
export class TextboxComponent
  extends BaseControl {
}

Datepicker

import { Component } from '@angular/core';
import { BaseControl } from '../base-control';
 
@Component({
    selector: 'app-date',
    templateUrl: './date.component.html',
})
export class DateComponent
  extends BaseControl {
}
<div class="form-group mb-1" [formGroup]="detailsForm">
  <label >{{controlLabel}}</label>
  <div class="input-group">
    <input [formControlName]="controlKey" class="form-control" ngbDatepicker #d="ngbDatepicker">
    <div class="input-group-append">
      <button class="input-group-append btn btn-outline-secondary" (click)="d.toggle()" type="button">
        <i class="fa fa-calendar"></i>
      </button>
    </div>
  </div>
</div>

ScrollToInvalidFieldDirective

Durante i test su form molto complesse, mi sono accorto che, scrollando la pagina e cliccando sul pulsante di invio, errori di validazione non visibili non facevano capire all’utente quale fosse il problema per il quale il click non desse nessun feedback. Ho voluto aggiungere quindi una funzionalità che, in presenza di campi non validi, scrollasse in automatico la pagina, mostrandomi il primo campo non valido.

Mediante una direttiva posta sul submit della form, intercetto il click dello stesso e, con una semplice query, verifico se vi sono elementi con classe ng-invalid: in caso positivo scrollo al primo trovato.

import { Directive, HostListener } from '@angular/core';
 
@Directive({
  selector: '[scrollToInvalidField]'
})
export class ScrollToInvalidFieldDirective {
  @HostListener('click') onClick() {
    const elementList = document.querySelectorAll('input.ng-invalid');
    const element = elementList[0] as HTMLElement;
    if (element) {
      element.scrollIntoView({ behavior: 'smooth' });
    }
  }
}

Semplice ed efficace

Potete scaricare il codice qui: https://github.com/AARNOLD87/AngularDymanicForm

Alla prossima!