In questo articolo descrivo la creazione di un validator custom asincrono da utilizzare nei Reactive Forms inclusi nell’ultima release del framework Angular.
Nello specifico, andremo a realizzare un form per l’inserimento di un nuovo utente, creando un validatore ad-hoc per verificare la presenza di tale utente su un database attraverso l’utilizzo di un servizio REST, che restituirà un errore, bloccando inoltre la possibilità di effettuare il submit del form, nel caso fosse già presente.
Angular Form
Angular include nel proprio framework due tipologie di form:
- Template driven-form: che al contrario di quanto molti pensino sono davvero molto potenti e permettono di realizzare form complessi senza alcuna riga di codice Javascript (o quasi), semplicemente inserendo nel template HTML direttive built-in e/o custom.
- Reactive Forms: oggetto di questo articolo, che forniscono invece una notevole flessibilità rispetto ai primi e sono più semplici da debuggare e testare: ad esempio, è possibile modificare validatori a runtime, creare dinamicamente form a partire da una configurazione, suddividere un form in diversi componenti e gruppi”, e molto altro
Su entrambe le tipologie di form esistono moltissime risorse e documentazione. Tuttavia, ho trovato diversi esempi sulla creazione di validatori sincroni (anche nella documentazione ufficiale) ma scarso materiale, invece, sui validatori custom asincroni nel caso di Reactive Forms. Ho pensato quindi di scrivere questo articolo presentando un esempio molto semplice.
Creare un form reattivo
1) Per creare un form reattivo, è necessario innanzitutto inserire un form e un campo di input all’interno di un template HTML. A tal proposito utilizziamo solo un paio degli strumenti forniti dal framework per gestire questa tipologia di form: formGroup
e formControlName
L’obiettivo finale dell’articolo sarà quello di creare un validatore custom per verificare se il nome utente è già presente su un database
<form [formGroup]="form" (submit)="save()"> <input type="text" formControlName="name" placeholder="Search a free username (min 3 chars)" > <button [disabled]="form.invalid"> Send </button> </form>
2) È infine necessario inizializzare il form e i vari controlli. Per farlo utilizzeremo una delle utility fornite dal framework, ovvero FormBuilder
:
- usiamo quindi
FormBuilder
per inizializzare il form. - viene inizializzato il campo
name
per il quale definiamo il valore di default,null
, come primo parametro, e un paio di validatori built-in, forniti in Angular (required
eminlength
), come secondo parametro. - Oltre ai validatori inclusi in Angular, potremmo aggiungere anche dei validatori custom sincroni, che tuttavia sono molto semplici da realizzare e non sono oggetto di questo articolo
- il metodo
save()
sarà quindi invocato al submit e il pulsante inserito nel template sarà disabilitato fino a che tutti i campi del form non saranno appunto validi (in questo caso solo uno per non complicare troppo il tutorial e focalizzarci sul validatore)
import { Component } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; @Component({ selector: 'my-app', templateUrl: './app.component.html', }) export class AppComponent { form: FormGroup; constructor(private fb: FormBuilder) { } ngOnInit() { this.form = this.fb.group({ 'name': [ // initial value null, // built-in validators Validators.compose( [ Validators.required, Validators.minLength(3), anyCustomSyncValidator ], ) ], }); } save() { console.log('save to db'); } }
Introduzioni ai custom AsyncValidator
Per creare un async validator da utilizzare nei Reactive Form è necessario creare una semplice funzione che restituisca un oggetto di tipo AsyncValidatorFn
. Dal source code di Angular possiamo verificare che tale tipo è rappresentato da una Promise o da un Observable di ValidationErrors
, che di seguito ricopio:
export interface AsyncValidatorFn { (c: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>; }
export declare type ValidationErrors = { [key: string]: any; };
Quindi, nel nostro caso, se volessimo creare uno UserValidator
che verifichi la presenza di un utente su un database, potremmo creare una funzione come la seguente:
Il servizio
HttpClient
fornito da Angular restituisce unObservable
e utilizziamo l’operatoremap
per trasformare il risultato nell’oggetto richiesto dall’interfaccia del validatore
userValidator(): AsyncValidatorFn { return (control: AbstractControl): Observable<{ [key: string]: any } | null> => { return this.http.get(URL_API?user=YOUR_USERNAME) .pipe( map(res => { // if username is taken if (CONDITION) { // return error (key: value) return { 'customErrorName': true}; } }) ); };
Creazione del validatore userValidator
Tra i vari possibili approcci che possiamo utilizzare per la creazione di un validatore riutilizzabile, dato che in Angular abbiamo la possibilità di sfruttare il motore di Dependency Injection, ho scelto la creazione di un servizio.
Di seguito creiamo quindi un service che interroga un’API gratuita (jsonplaceholder) per verificare se l’utente è già presente sul suo database o meno (in realtà è un JSON ma per questo esempio è più che sufficiente).
Nel caso l’utente fosse già stato inserito, ci verrà restituito un array con un elemento. In caso negativo, un array vuoto.
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { AbstractControl, AsyncValidatorFn } from '@angular/forms'; import { Observable, timer } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; export const URL = 'https://jsonplaceholder.typicode.com'; @Injectable({ providedIn: 'root' }) export class UserValidators { constructor(private http: HttpClient) {} userValidator(): AsyncValidatorFn { return (control: AbstractControl): Observable<{ [key: string]: any } | null> => { return this.http.get<any>(`${URL}/users?username=${control.value}`) .pipe( map(res => { // return a custom error if username is taken (result is an array with one or more elements), // otherwise return no error if the array is empty return (res.length) ? { 'userNameExists': true} : null } }) ); }; } }
Analizziamo brevemente lo script precedente:
- La classe utilizza la funzionalità
providedIn
del decoratorInjectable
, disponibile da Angular 6, per definire il provider nel modulo root senza la necessità di doverlo specificare manualmente nel modulo - Il metodo
userValidator
sarà invocato ogni volta che un utente digiterà un carattere nel campo di input ed effettuerà la chiamata ad un ipotetico servizio REST per la validazione dello username ottenuto dal campo di input(control.value
) - Utilizziamo l’operatore
map
di RxJS per trasformare il risultato in un oggetto che rappresenti il tipo di errore da utilizzare nella UI per visualizzare un messaggio di errore ad hoc
Utilizzare il validatore
Come utilizzare questo validator asincrono? Aggiorniamo il codice in cui abbiamo inizializzato il form, iniettiamo il nuovo servizio e inseriamo il validatore come terzo parametro del metodo group()
, utilizzato proprio per l’inserimento di validatori asincroni.
import { Component } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { UserValidators } from './validators/user.validator'; @Component({ selector: 'my-app', templateUrl: './app.component.html', }) export class AppComponent { form: FormGroup; constructor( private fb: FormBuilder, private service: UserValidators ) { } ngOnInit() { this.form = this.fb.group({ 'name': [ // initial value null, // sync built-in validators Validators.compose( [ Validators.required, Validators.minLength(3) ], ), // custom async validator this.service.userValidator() ], }); } save() { console.log('save to db'); } }
Il template HTML ora sarà in grado di gestire:
- la proprietà
pending
, per mostrare eventuali loader/spinner/testi durante la fase di caricamento - una nuova tipologia di errore,
userNameExists
, nel caso di username non disponibile
<form [formGroup]="form" (submit)="save()"> <p>Find a free username</p> <input style="width: 220px" type="text" formControlName="name" placeholder="Search a free username (min 3 chars)" > <button [disabled]="form.invalid || form.pending"> Send <span *ngIf="form.get('name').pending"> (searching)</span> </button> <p style="color: red" *ngIf="form.get('name').errors?.userNameExists">Not available</p> </form>
Debounce
L’attuale validatore ha un grosso difetto: viene invocato dopo la digitazione di ogni carattere, mentre sarebbe più opportuno applicare un debounce per effettuare la validazione solo TOT millisecondi dopo il termine dell’inserimento.
Potremmo “wrappare” la nostra chiamata HTTP in un setTimeout
allo scopo di ritardare la richiesta applicando un delay e distruggendo il timer (clearInterval
) ad ogni digitazione.
Bene…. ma non benissimo 🙂
Potremmo invece utilizzare l’operatore debounceTime(value)
fornito da RxJS. Tuttavia non è possibile farlo all’interno del validatore ma dovremmo applicarlo in fase di inizializzazione del nostro form (ad es. sfruttando la proprietà valueChanges
dei controlli), quindi nel componente che lo definisce.
Bello… ma non stupendo. Se volessimo infatti riutilizzare questo validatore dovremmo duplicare questa procedura in ogni form e scrivere un bel po’ di codice in più.
Un modo più “elegante” e flessibile è invece quello di inserire questa logica all’interno del validatore utilizzando l’operatore timer
associato ad un switchMap
, come vedrete di seguito.
Tuttavia, per evitare un codice poco leggibile, ho preferito dividere in due differenti metodi il validatore, come vedrete di seguito:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { AbstractControl, AsyncValidatorFn } from '@angular/forms'; import { Observable, timer } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; export const URL = 'https://jsonplaceholder.typicode.com'; @Injectable({ providedIn: 'root' }) export class UserValidators { constructor(private http: HttpClient) {} searchUser(text) { // debounce return timer(1000) .pipe( switchMap(() => { // Check if username is available return this.http.get<any>(`${URL}/users?username=${text}`) }) ); } userValidator(): AsyncValidatorFn { return (control: AbstractControl): Observable<{ [key: string]: any } | null> => { return this.searchUser(control.value) .pipe( map(res => { // if username is already taken if (res.length) { // return error return { 'userNameExists': true}; } }) ); }; } }
DEMO
Il codice completo e funzionante è disponibile su StackBlitz:
Leave A Comment