10. Formulaires réactifs (Reactive Forms)

0

Lors du chapitre précédent, nous avons vu comment créer un application avec différentes routes. Aujourd’hui nous allons voir comment créer des formulaires réactifs en Angular. Pour cela nous allons:

  • expliquer les différents type de formulaires, dont les « Reactive Forms » (« Formulaires réactifs » en français)
  • nous allons voir comment créer un premier formulaire réactif avec FormControl
  • on verra comment utiliser des validateurs, afin de valider les entrées de nos utilisateurs
  • on verra également comment regrouper des inputs avec FormGroup
  • ensuite on parler de FormBuilder, qui nous permettra de créer des formulaires plus simplement
  • et pour finir, on verra comment réagir à des changement de valeurs dans nos formulaires

A la fin de ce chapitre on aura un formulaire simple qui nous permettra de créer et modifier un objet de collection.

Pour rappel, tout au long de ce cours, on va créer une application de gestion de collections de timbres, pièces, figurines, cartes à jouer ou autre.

Les différents types de formulaires

Au jour d’aujourd’hui, il existe trois types de formulaires que vous pouvez utiliser en Angular. Deux de ces types sont stables et sont ceux que vous retrouverez dans la totalité des projets Angular: les « template-driven forms » et les « reactive forms ». Depuis la version 21, un nouveau type de formulaires a été ajouté à cette liste en mode expérimental: les « signal forms ».

Template-driven forms

Le premier type de formulaire dont on va parler, et qu’on a déjà vu dans ce cours, sont les « template-driven forms ». Ces formulaires consistent à utiliser le two-way binding avec la propriété ngModel afin de synchroniser l’état du formulaire avec nos variables TypeScript.

Pare exemple si on souhaite écrire un « template-driven form » qui demande un nom à notre utilisateur et l’imprime dans la console lors de l’envoie. On peut faire la chose suivante :

collection-item-detail.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-collection-item-detail',
  imports: [FormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  name = '';

  submit(event: Event) {
    event.preventDefault();
    console.log(this.name);
  }

}
collection-item-detail.html
<form (submit)="submit($event)">
  <label>Name : </label>
  <input name="name" [(ngModel)]="name" /><br/>
  <button type="submit">Submit</button>
</form>

Ce qui nous donne le résultat suivant :

Reactive forms

En plus des « template-driven forms », Angular nous met à disposition un autre type de formulaires, nommés « reactive forms », et qui permettent de définir le comportement de nos formulaires de manière déclarative, dans notre code TypeScript. En bref, on va pouvoir indiquer dans notre fichier TypeScript, pour chaque champ de notre formulaire, leurs valeurs par défaut ainsi que leurs règles de validation respectives. En plus de cela, on pourra également s’abonner à tout changement de valeur de nos champs, afin d’y réagir en temps réel, si besoin.

Comme le reste de ce chapitre est dédié à ce type de formulaires, on ne va pas élaborer le sujet ici, étant donné qu’on aura plusieurs sections qui vont détailler chacun des aspect mentionnés plus tôt.

Signal forms

Le dernier type de formulaires dont on va parler, sont les « signal forms », qui sont encore au stade expérimental. Les « signal forms » permettent de créer des formulaires basés sur les signaux. Ici on commence par définir un modèle de données sous forme de signal, qui est ensuite utilisé pour définir un formulaire en utilisant la fonction form. La fonction form, peut aussi prendre en paramètre un dictionnaire contenant les règles de validation des différents champs :

collection-item-detail.ts
import { Component, signal } from '@angular/core';
import { form, required, Field } from '@angular/forms/signals';

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, Field],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  formModel = signal({
    'name': ''
  });

  testForm = form(this.formModel, (schemaPath) => {
    required(schemaPath.name, {message: 'Name is required'})
  })

  submit(event: Event) {
    event.preventDefault();
    console.log(this.testForm().value());
  }

}
collection-item-detail.html
<form (submit)="submit($event)">
  <label>Name : </label>
  <input [field]="testForm.name" /><br/>
  <button type="submit" [disabled]="!testForm().valid()">Submit</button>
</form>

Si on regarde le résultat on voir qu’on récupère bien un objet avec name qui correspond à la valeur entrée par l’utilsateur

Reactive Forms

Passons maintenant au coeur de ce chapitre: les « Reactive Forms ». Avant de voir comment créer des formulaires réactifs préparons notre code. Pour cela on va ouvrir notre fichier « collection-item-detail.html » et on va créer un formulaire avec un input pour le nom de notre objet de collection et un bouton « Submit » :

collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name"/><br/>
    </div>
    <button type="submit">Submit</button>
</form>

Ajoutons aussi un peu de CSS à notre composant pour placer notre input et notre bouton proprement :

collection-item-detail.scss
form {
    max-width: 300px;
}

.form-field {
    display: flex;
    flex-direction: column;
    margin: 10px 0;
}

label {
    font-size: small;
}

FormControl

Maintenant que notre formulaire est prêt, regardons comment lier notre input à notre code TypeScript. Pour cela, on va utiliser FormControl, qui nous permet d’un côté d’accéder à la valeur de notre input et d’un autre côté, comme on le verra dans un instant, FormControl nous permet également de déclarer des règles de validation pour notre champ et de nous abonner à chaque changement de la valeur de ce champ.

Pour utiliser FormControl, on doit tout d’abord ajouter ReactiveFormsModule aux imports de notre composant. A partir de là, on pourra créer des formulaires réactifs. ReactiveFormsModule ainsi que FormControl doivent être importés tous les deux de @angular/forms. Ouvrons donc notre fichier « colleciton-item-detail.ts » et créons une variable nameFormControl, à laquelle on va assigner un nouveau FormControl auquel on va passer en paramètre la valeur par défaut de notre input :

collection-item-detail.ts
import { Component, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormControl, ReactiveFormsModule } from "@angular/forms";

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  nameFormControl = new FormControl('');

  submit(event: Event) {
    event.preventDefault();
    console.log(this.nameFormControl.value);
  }

  private router = inject(Router);
  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

}

Notez que nous avons créé une méthode submit() qui imprime la valeur de notre FormControl, à laquelle on peut accéder en utilisant l’attribut value du FormControl.

Maintenant dans notre formulaire, nous devons indiquer que nous voulons lier notre input au nameFormControl, pour cela nous pouvons utiliser l’attribut formControl de l’input :

collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" [formControl]="nameFormControl"/><br/>
    </div>
    <button type="submit">Submit</button>
</form>

Si on ouvre le navigateur à l’URL http://localhost:4200/item, on voit bien que si on écrit un nom dans l’input et qu’on clique sur le bouton « Submit », on voit le texte de l’input apparaitre dans les logs de la console :

Regardons maintenant comment écrire une valeur dans notre nameFormControl de manière programmatique. Pour cela on peut utiliser la méthode setValue() à laquelle on passe la valeur qu’on souhaite assigner à notre FormControl. Pour illustrer cela, ajoutons un bouton à notre formulaire qui va utiliser setValue afin d’assigner le texte « Changed » à notre FormControl :

collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" [formControl]="nameFormControl"/><br/>
    </div>
    <button type="submit">Submit</button><br/>
    <button (click)="nameFormControl.setValue('changed')" type="button">Change</button>
</form>

Maintenant si on appuie sur le bouton « Change » qu’on vient de créer, on voit que notre input change de valeur et contient bien le texte « Changed » qui a été assigné à notre nameFormControl :

Validators (a.k.a: Les validateurs)

FormControl peut recevoir en tant que deuxième paramètre un tableau de « validators », ces validateurs sont ensuite appliqués à la valeur du FormControl et vérifient si le validateur satisfait la condition en question. Il existe différents « validators », comme par exemple le validateur Validators.required qui rend un champ obligatoire. On peut ensuite vérifier si un FormControl rempli bien toutes les conditions en utilisant l’attribut invalid, qui est à True si le FromControl ne respecte pas toutes les conditions de ses validateurs.

Si on veut rendre notre input obligatoire et ne rendre le bouton « Submit » actif uniquement si l’utilisateur à entré une valeur dans notre input, on peut faire la chose suivante :

collection-item-detail.ts
import { Component, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms";

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  nameFormControl = new FormControl('', [Validators.required]);

  submit(event: Event) {
    event.preventDefault();
    console.log(this.nameFormControl.value);
  }

  private router = inject(Router);
  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

}

Notez que Validators est à importer de @angular/forms.

Nous pouvons ensuite adapter notre HTML :

collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" [formControl]="nameFormControl"/><br/>
    </div>
    <button type="submit" [disabled]="nameFormControl.invalid">Submit</button>
</form>

Maintenant le bouton « Submit » est bien désactivé tant qu’on n’a pas tapé de valeurs dans l’input :

En plus de cela, ce serait bien qu’on puisse changer la couleur de notre input s’il est des erreurs de validation, et afficher un message pour indiquer à l’utilisateur quelle est l’erreur exacte qui est survenue.

Pour la partie design, c’est assez simple, FormControl va ajouter des classes CSS aux input selon leurs états. Entre autres, on aura :

  • une classe « ng-dirty » qui indique que l’input a été changé,
  • « ng-touched », qui indique que l’utilisateur a interagi avec l’input et
  • « ng-invalid » qui indique que l’input n’est pas valide.

On peut donc adapter notre CSS de la manière suivante :

collection-item-detail.scss
form {
    max-width: 300px;
}

.form-field {
    display: flex;
    flex-direction: column;
    margin: 10px 0;
}

label {
    font-size: small;
}

.ng-dirty.ng-invalid, .ng-touched.ng-invalid {
    border-color: red;
    border-width: 1px;
}

.error {
    font-size: x-small;
    color: red;
}

On a également ajouté une classe « error » à notre CSS, cette classe sera utile lorsque nous voudrons afficher des messages d’erreur à l’écran. Pour afficher un tel message d’erreur, nous pouvons utiliser les attributs invalid, dirty et touched de FormControl afin de déterminer si oui ou non un message doit être affiché :

collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" [formControl]="nameFormControl"/>
        @if (nameFormControl.invalid && 
                (nameFormControl.dirty || nameFormControl.touched)
        ) {
            <div class="error">This field is required!</div>
        }
    </div>
    <button type="submit" [disabled]="nameFormControl.invalid">Submit</button>
</form>

Maintenant si nous cliquons sur notre input et le quittons en le laissant vide, notre message d’erreur est affiché :

Voyons maintenant à quoi ressemblerait notre code, si on rajoutait un autre input à notre formulaire. Pour cela on va ajouter un nouveau champ qui va contenir le prix de notre objet de collection. Pour ce prix, en plus de le rendre obligatoire, on veut aussi que le nombre entré soit plus grand ou égale à 0. Pour cela on peut utiliser Validator.min() qui prend la valeur minimal accepté en paramètre.

collection-item-detail.ts
import { Component, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms";

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  nameFormControl = new FormControl('', [Validators.required]);
  priceFormControl = new FormControl(0, [Validators.required, Validators.min(0)]);

  submit(event: Event) {
    event.preventDefault();
    console.log(this.nameFormControl.value);
    console.log(this.priceFormControl.value);
  }

  private router = inject(Router);
  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

}
collection-item-detail.html
<form (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" [formControl]="nameFormControl"/>
        @if (nameFormControl.invalid && 
                (nameFormControl.dirty || nameFormControl.touched)
        ) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="price">Price : </label>
        <input id="price" name="price" [formControl]="priceFormControl"/>
        @if (priceFormControl.invalid && 
                (priceFormControl.dirty || priceFormControl.touched)
        ) {
            <div class="error">This field is required!</div>
        }
    </div>
    <button type="submit" 
        [disabled]="nameFormControl.invalid || priceFormControl.invalid"
    >
        Submit
    </button>
</form>

Comme attendu, si on essaie d’entrer un nombre plus petit que 0 en tant que prix, nous voyons bien notre erreur s’afficher :

Ce code fonctionne bien, mais au fur et à mesure qu’on ajoute des FormControl, on se retrouve avec de plus en plus de d’états invalides à gérer et quand on voudra récupérer les valeurs de ces différents FormControl, on devra également traiter chaque valeur de manière individuelle, ce qui rend le code rapidement complexe et illisible.

FormGroup

Afin de simplifier l’utilisation des FromControl dans de grand formulaires, on peut utiliser un FormGroup, qui peut également être importé de @angular/forms. FormGroup permet de regrouper plusieurs FormControl, et nous permet de vérifier si tout le formulaire est valide en un seule appel, et nous permet également de récupérer toutes les valeurs du formulaire sous form d’un dictionnaire.

FormGroup prend un dictionnaire en tant que paramètre de son constructeur, dont les clés sont les noms des FormControl et les valeurs sont les objets FormControl en question, que nous souhaitons lui assigner.

Afin d’illustrer tout cela, je propose de créer un FormGroup qui va contenir tous les champs nécessaire afin de créer un CollectionItem. Mais avant cela on va modifier un peu notre code afin, pour le moment nous avons une classe CollectionItem qui a un attribut rarity qui peut contenir un string quelconque. Afin de limiter les choix possibles, on va créer un type Rarity qui va énumérer les raretés qu’on pourra utiliser :

collection-item.ts
export const Rarities = {
  Legendary: 'Legendary',
  Rare: 'Rare',
  Uncommon: 'Uncommon',
  Common: 'Common',
} as const;
export type Rarity = typeof Rarities[keyof typeof Rarities];

export class CollectionItem {

    id = -1;
    name = "Linx";
    description = "A legendary sword of unmatched sharpness and history.";
    image = "img/linx.png"
    rarity: Rarity = "Legendary";
    price = 250;

    copy(): CollectionItem {
        return Object.assign(new CollectionItem(), this);
    }

}

Nous devons encore adapter la classe CollectionService, afin de n’y utiliser que des raretés valides :

collection-service.ts
import { Injectable } from '@angular/core';
import { Collection } from '../models/collection';
import { CollectionItem } from '../models/collection-item';

@Injectable({
  providedIn: 'root'
})
export class CollectionService {

  private collections: Collection[] = [];
  private currentId = 1;
  private currentItemIndex: {[key: number]: number} = {};

  constructor() {
    this.load();
  }

  private save() {
    localStorage.setItem('collections', JSON.stringify(this.collections));
  }

  private load() {
    const collectionsJson = localStorage.getItem('collections');
    if (collectionsJson) {
      this.collections = JSON.parse(collectionsJson).map((collectionJson: any) => {
        const collection = Object.assign(new Collection(), collectionJson);
        const itemsJson = collectionJson['items'] || [];
        collection.items = itemsJson.map((item: any) => Object.assign(new CollectionItem, item));
        return collection;
      });
      this.currentId = Math.max(...this.collections.map(collection => collection.id)) + 1;
      this.currentItemIndex = this.collections.reduce(
        (indexes: {[key: number]: number}, collection) => {
          indexes[collection.id] = Math.max(...collection.items.map(item => item.id)) + 1;
          return indexes;
        }, {}
      );
    } else {
      this.generateDummyData();
      this.save();
    }
  }

  generateDummyData() {
    const coin = new CollectionItem();
    coin.name = 'Pièce de 1972';
    coin.description = 'Pièce de 50 centimes de francs.';
    coin.rarity = 'Commune';
    coin.image = 'img/coin1.png';
    coin.price = 170;
      
    const stamp = new CollectionItem();
    stamp.name = 'Timbre 1800';
    stamp.description = 'Un vieux timbre';
    stamp.rarity = 'Rare';
    stamp.image = 'img/timbre1.png';
    stamp.price = 555;
      
    const linx = new CollectionItem();
      
    const defaultCollection = new Collection();
    defaultCollection.title = "Collection mix";
    
    const storedCollection = this.add(defaultCollection);
    this.addItem(storedCollection, coin);
    this.addItem(storedCollection, linx);
    this.addItem(storedCollection, stamp);
  }

  getAll(): Collection[] {
    return this.collections.map(collection => collection.copy());
  }

  get(collectionId: number): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collectionId
    );

    if (!storedCopy) return null;
    return storedCopy.copy();
  }

  add(collection: Omit<Collection, 'id' | 'items'>): Collection {
    
    const storedCopy = collection.copy();
    storedCopy.id = this.currentId;
    this.collections.push(storedCopy);

    this.currentItemIndex[storedCopy.id] = 1;
    this.currentId++;
    this.save();

    return storedCopy.copy();

  }

  update(collection: Omit<Collection, 'items'>): Collection | null {
    const storedCopy = this.collections.find(
      collection => collection.id === collection.id
    );

    if (!storedCopy) return null;
   
    Object.assign(storedCopy, collection);
    this.save();
    return storedCopy.copy();

  }

  delete(collectionId: number): void {
    this.collections = this.collections.filter(
      collection => collection.id !== collectionId
    );
    this.save();
  }

  addItem(collection: Collection, item: CollectionItem): Collection | null {
    const storedCollection = this.collections.find(
      collection => collection.id === collection.id
    );
    
    if (!storedCollection) return null;
    
    const storedItem = item.copy();
    storedItem.id = this.currentItemIndex[collection.id];
    storedCollection.items.push(storedItem);

    this.currentItemIndex[collection.id]++;
    this.save();

    return storedCollection.copy();
  }

  updateItem(collection: Collection, item: CollectionItem) {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collection.id
    );
    
    if (!storedCollection) return null;

    const storedItemIndex = storedCollection.items.findIndex(
      storedItem => storedItem.id === item.id
    )

    if (storedItemIndex === -1) return null;

    storedCollection.items[storedItemIndex] = item.copy();
    this.save();

    return storedCollection.copy();
  }
  
  deleteItem(collectionId: number, itemId: number): Collection | null {
    const storedCollection = this.collections.find(
      storedCollection => storedCollection.id === collectionId
    );
    
    if (!storedCollection) return null;

    storedCollection.items = storedCollection.items.filter(
      item => item.id !== itemId
    )
    this.save();

    return storedCollection.copy();
  }
}

Maintenant attaquons nous à notre fichier collection-item-detail.ts et adaptons le afin d’utiliser un FormGroup :

  • ajoutons un attribut rarities qui va contenir la liste de toutes les raretés autorisées et qu’on pourra utiliser dans notre HTML afin de créer un select
  • ajoutons un attribut itemFromGroup qui va contenir un FormGroup avec un FormControl par attribut de notre classe CollectionItem
  • modifions la méthode submit afin que le console.log affiche la valeur du FormGroup
  • créons une méthode isFieldValid, qui prend un nom d’un FormControl en paramètre et vérifie si le champ est valide
  • pour finir, créons une méthode onFileChange, qui sera utilisée pour récupérer une image choisie par l’utilisateur, la convertir en base64 et la stocker dans le FormControl approprié.

Regardons tout de suite à quoi ressemble le code et puis nous reviendrons sur certaines de ces fonctions un peu plus en détail :

collection-item-detail.ts
import { Component, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Rarities } from '../../models/collection-item';

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  rarities = Object.values(Rarities);

  itemFormGroup = new FormGroup({
    name: new FormControl('', [Validators.required]),
    description: new FormControl('', [Validators.required]),
    image: new FormControl('', [Validators.required]),
    rarity: new FormControl(Rarities.Common, [Validators.required]),
    price: new FormControl(0, [Validators.required, Validators.min(0)])
  });

  submit(event: Event) {
    event.preventDefault();
    console.log(this.itemFormGroup.value);
  }

  private router = inject(Router);
  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

  isFieldValid(fieldName: string) {
    const formControl = this.itemFormGroup.get(fieldName);
    return formControl?.invalid && (formControl?.dirty || formControl?.touched);
  }
  
  onFileChange(event: any) {
    const reader = new FileReader();
    if (event.target.files && event.target.files.length) {
      const [file] = event.target.files;
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.itemFormGroup.patchValue({
          image: reader.result as string
        });
      };
    }
  }

}

Revenons sur les méthodes isFieldValid et onFileChange. isFieldValid, prend un nom de FormControl en paramètre afin de vérifier si celui-ci est valide en utilisant les attributs invalid, dirty et touched. Pour cela, on utilise la méthode get de FromGroup, qui prend un nom de FormControl à retourner en paramètre. Une fois le FormControl récupéré, on peut vérifier si celui-ci est valide.

Pour ce qui est de la méthode onFileChange, cette méthode sera utilisée afin de convertir l’image sélectionnée par l’utilisateur afin de convertir celle-ci en base64 et ensuite la stocker dans le FormControl approprié. Pour ce faire on utilise la méthode patchValue du FormGroup qui prend un dictionnaire en paramètre avec les valeurs des FormControl à remplacer. Dans notre cas on passe un dictionnaire avec la clé « image » et l’image en base64 en tant que valeur.

Maintenant que notre fichier TypeScript est prêt, modifions notre HTML afin d’y afficher les différents inputs dont on aura besoin afin de saisir un CollectionItem:

collection-item-detail.html
<form [formGroup]="itemFormGroup" (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" formControlName="name"/>
        @if (isFieldValid('name')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="description">Description : </label>
        <input id="description" name="description" formControlName="description"/>
        @if (isFieldValid('description')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="image">Image</label>
        <input id="image" name="image" type="file" (change)="onFileChange($event)">
        @if (isFieldValid('image')) {
            <div class="error">This field is required.</div>
        }
    </div>
    <div class="form-field">
        <label for="rarity">Rarity</label>
        <select id="rarity" name="rarity" formControlName="rarity">
        @for (rarity of rarities; track rarity) {
            <option [value]="rarity">{{rarity}}</option>
        }
        </select>
    </div>
    <div class="form-field">
        <label for="price">Price : </label>
        <input id="price" name="price" formControlName="price" type="number"/>
        @if (isFieldValid('price')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <button type="submit" [disabled]="itemFormGroup.invalid">
        Submit
    </button>
</form>

Et voilà, nous avons maintenant un formulaire complet :

Avant de passer au FormBuilder, il y a une dernière chose que j’aimerai adapter dans notre formulaire. Pour l’instant, si une validation ne passe pas, on affiche simplement le message: « This field is required ». Ceci est un problème pour le champ prix, qui a deux validateurs, le premier vérifie effectivement que le champ soit bien renseigné, mais le deuxième vérifie en plus de cela que le prix entré est plus grand ou égale à 0.

Pour le moment, si l’utilisateur entre un prix négatif, on lui affiche donc le mauvais message d’erreur. Afin de pouvoir différencier l’erreur de validation d’un FormControl, on peut utiliser la méthode hasError de celui-ci, en lui passant le nom du validateur en question en paramètre. Si on veut savoir si un FormControl est invalid à cause du validateur required, on peut donc vérifier la valeur de hasError(‘required’) et si on veut vérifier si l’erreur vient du validateur min, on peut utiliser hasError(‘min’) :

collection-item-detail.html
<form [formGroup]="itemFormGroup" (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" formControlName="name"/>
        @if (isFieldValid('name')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="description">Description : </label>
        <input id="description" name="description" formControlName="description"/>
        @if (isFieldValid('description')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="image">Image</label>
        <input id="image" name="image" type="file" (change)="onFileChange($event)">
        @if (isFieldValid('image')) {
            <div class="error">This field is required.</div>
        }
    </div>
    <div class="form-field">
        <label for="rarity">Rarity</label>
        <select id="rarity" name="rarity" formControlName="rarity">
        @for (rarity of rarities; track rarity) {
            <option [value]="rarity">{{rarity}}</option>
        }
        </select>
    </div>
    <div class="form-field">
        <label for="price">Price : </label>
        <input id="price" name="price" formControlName="price" type="number"/>
        @if (isFieldValid('price')) {
            @let priceFormControl = itemFormGroup.get('price');
            @if (priceFormControl?.hasError('required')) {
                <div class="error">This field is required!</div>
            }
            @if (priceFormControl?.hasError('min')) {
                <div class="error">The value must be bigger or equal to 0!</div>
            }
        }
    </div>
    <button type="submit" 
        [disabled]="itemFormGroup.invalid"
    >
        Submit
    </button>
</form>

Cette fois-ci, si on entre un nombre négatif dans le champ « price » on voit bien le message d’erreur approprié qui s’affiche :

FormBuilder

Nous avons maintenant un FormGroup fonctionnel, mais on peut encore simplifier notre code à l’aide d’un FormBuilder. FormBuilder, qui doit lui aussi être importer de @angular/forms, possède une méthode « group », qui va générer le FormGroup pour nous et prend en paramètre un dictionnaire, avec en tant que clé, les différents noms des FormControl à créer, et en tant que valeurs, un tableau qui va contenir :

  • en tant que premier élément la valeur par défaut du FormControl
  • et, en tant que deuxième élément optionnel, un tableaux de validateur.

Le résultat de l’appel à cette fonction group est exactement le même que la création manuelle du FormGroup, mais a l’avantage d’être plus condensé:

collection-item-detail.ts
import { Component, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Rarities } from '../../models/collection-item';

@Component({
  selector: 'app-collection-item-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail {

  private readonly fb = inject(FormBuilder);

  rarities = Object.values(Rarities);

  itemFormGroup = this.fb.group({
    name: ['', [Validators.required]],
    description: ['', [Validators.required]],
    image: ['', [Validators.required]],
    rarity: [Rarities.Common, [Validators.required]],
    price: [0, [Validators.required, Validators.min(0)]]
  });

  submit(event: Event) {
    event.preventDefault();
    console.log(this.itemFormGroup.value);
  }

  private router = inject(Router);
  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

  isFieldValid(fieldName: string) {
    const formControl = this.itemFormGroup.get(fieldName);
    return formControl?.invalid && (formControl?.dirty || formControl?.touched);
  }
  
  onFileChange(event: any) {
    const reader = new FileReader();
    if (event.target.files && event.target.files.length) {
      const [file] = event.target.files;
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.itemFormGroup.patchValue({
          image: reader.result as string
        });
      };
    }
  }

}

Qu’on crée nos FormGroup à la main, ou en utilisant un FormBuilder le résultat reste le même :

S’abonner aux changements

Maintenant que notre formulaire est prêt, regardons comment ajouter un aperçu de notre objet de collection qui s’adapte au fur et à mesure que l’utilisateur entre les informations de l’objet. Pour cela, nous pouvons faire appel à la méthode subscribe() de l’attribut valueChanges de notre FormGroup, afin de nous abonner à tout changement de valeur de notre formulaire.

Pour illustrer cela on va modifier notre CollectionItemDetail comme suit :

  • on va importer CollectionItemCard qu’on utilisera afin d’afficher la prévisualisation de notre objet de collection
  • on va injecter notre CollectionService
  • on va créer un signal nomé collectionItem
  • dans le constructor, on va ajouter un effect, qui lorsque le signal itemId est mis à jour, va récupérer l’item à afficher / modifier. Si aucun identifiant n’est renseigné, on crée un CollectionItem par défaut. Ensuite, on met à jour le FormGroup avec les informations qu’on aura récupéré.
  • on va créer un attribut valueChangeSubscription, qui va contenir notre Subscription qu’on va faire à valueChanges
  • En plus de cela on va implementer OnInit et OnDestroy
    • Dans le constructor, on va faire un subscribe() au valueChanges de notre FormGroup et à chaque changement on va créer un nouvel objet CollectionItem auquel on va assigner les valeurs actuelles du formulaire, et on va assigner ce CollectionItem à notre signal
    • Pour finir on n’oublions pas de faire un unsubscribe de this.valueChangeSubscription dans la méthode ngOnDestroy
collection-item-detail.ts
import { Component, effect, inject, input, OnDestroy, signal } from '@angular/core';
import { Router } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { CollectionItem, Rarities } from '../../models/collection-item';
import { CollectionItemCard } from "../../components/collection-item-card/collection-item-card";
import { Subscription } from 'rxjs';
import { CollectionService } from '../../services/collection-service';
import { Collection } from '../../models/collection';

@Component({
  selector: 'app-collection-item-detail',
  imports: [ReactiveFormsModule, CollectionItemCard],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail implements OnDestroy {

  private readonly fb = inject(FormBuilder);
  private readonly router = inject(Router);
  private readonly collectionService = inject(CollectionService);

  rarities = Object.values(Rarities);

  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

  selectedCollection!: Collection;
  collectionItem = signal<CollectionItem>(new CollectionItem());

  itemFormGroup = this.fb.group({
    name: ['', [Validators.required]],
    description: ['', [Validators.required]],
    image: ['', [Validators.required]],
    rarity: ['', [Validators.required]],
    price: [0, [Validators.required, Validators.min(0)]]
  });

  valueChangeSubscription: Subscription | null = null;

  constructor() {
    effect(() => {
      let itemToDisplay =  new CollectionItem();
      this.selectedCollection = this.collectionService.getAll()[0];
      if (this.itemId()) {
        const itemFound = this.selectedCollection.items.find(item => item.id === this.itemId());
        if (itemFound) {
          itemToDisplay = itemFound;
        } else {
          this.router.navigate(['not-found']);
        }
      }
      this.itemFormGroup.patchValue(itemToDisplay);
    });
    this.valueChangeSubscription = this.itemFormGroup.valueChanges.subscribe(_ => {
      this.collectionItem.set(Object.assign(new CollectionItem(), this.itemFormGroup.value));
    });
  }

  submit(event: Event) {
    event.preventDefault();
    console.log(this.itemFormGroup.value);
  }

  isFieldValid(fieldName: string) {
    const formControl = this.itemFormGroup.get(fieldName);
    return formControl?.invalid && (formControl?.dirty || formControl?.touched);
  }
  
  onFileChange(event: any) {
    const reader = new FileReader();
    if (event.target.files && event.target.files.length) {
      const [file] = event.target.files;
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.itemFormGroup.patchValue({
          image: reader.result as string
        });
      };
    }
  }

  ngOnDestroy(): void {
    this.valueChangeSubscription?.unsubscribe();
  }

}

Maintenant, nous pouvons modifier notre HTML afin d’y ajouter la collection-item-card qui servira à prévisualiser l’objet de collection. Et on en profite aussi pour changer le texte de notre bouton « Submit » en « Save » :

collection-item-detail.html
<app-collection-item-card [item]="collectionItem()" />

<form [formGroup]="itemFormGroup" (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" formControlName="name"/>
        @if (isFieldValid('name')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="description">Description : </label>
        <textarea id="description" name="description" formControlName="description" rows="5"></textarea>    
        @if (isFieldValid('description')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="image">Image</label>
        <input id="image" name="image" type="file" (change)="onFileChange($event)">
        @if (isFieldValid('image')) {
            <div class="error">This field is required.</div>
        }
    </div>
    <div class="form-field">
        <label for="rarity">Rarity</label>
        <select id="rarity" name="rarity" formControlName="rarity">
        @for (rarity of rarities; track rarity) {
            <option [value]="rarity">{{rarity}}</option>
        }
        </select>
    </div>
    <div class="form-field">
        <label for="price">Price : </label>
        <input id="price" name="price" formControlName="price" type="number"/>
        @if (isFieldValid('price')) {
            @let priceFormControl = itemFormGroup.get('price');
            @if (priceFormControl?.hasError('required')) {
                <div class="error">This field is required!</div>
            }
            @if (priceFormControl?.hasError('min')) {
                <div class="error">The value must be bigger or equal to 0!</div>
            }
        }
    </div>
    <button type="submit" 
        [disabled]="itemFormGroup.invalid"
    >
        Save
    </button>
</form>

Il ne nous reste plus qu’à adapter le CSS pour placer le formulaire à côté de la prévisualisation :

collection-item-detail.scss
form {
    max-width: 300px;
}

.form-field {
    display: flex;
    flex-direction: column;
    margin: 10px 0;
}

label {
    font-size: small;
}

.ng-dirty.ng-invalid, .ng-touched.ng-invalid {
    border-color: red;
    border-width: 1px;
}

.error {
    font-size: x-small;
    color: red;
}

app-collection-item-card {
    margin: 10px 0;
}

:host {
    display: flex;
    gap: 2rem;
    justify-content: center;
}

Et le tour est joué. Nous pouvons ouvrir un item existant en navigant à une URL contenant un identifiant d’un item de la collection par défaut (par exemple: http://localhost:4200/item/1) ou créer un ouvrir une page de création d’un nouvel item en navigant vers la page http://localhost:4200/item.

Pour les lecteurs plus avancés : Vous vous demandez peut-être pourquoi on ne converti pas, valueChanges en signal, ou encore pourquoi on n’utilise pas takeUntilDestroyed. La raison est que nous sommes dans un cours débutant ou nous n’avons pas encore abordé ces sujets. Donc n’hésitez pas à adapter le code avec ces notions, si vous le désirez.

Exercice corrigé

Maintenant que nous avons un formulaire prêt à être utilisé, il est temps de mettre tout ce que nous avons appris en pratique. Pour cela je vous propose d’implémenter l’exercice suivant :

  1. Modifiez le composant CollectionDetail afin que lorsqu’on clique sur un objet de collection, on soit redirigé sur le formulaire de modification de celui-ci (url: http://localhost:4200/item/:id).
  2. Ajoutez un bouton « Add Item » au composant CollectionDetail, qui redirige sur le formulaire d’ajout d’objets de collections (url: http://localhost:4200/item).
  3. Modifiez le composant CollectionItemDetail de la manière suivante :
    • Le bouton « Save » doit utiliser le CollectionService afin de modifier l’objet ouvert, ou d’en créer un nouveau dépendant de l’URL visitée.
    • Ajoutez un bouton « Delete » qui va utiliser le CollectionService afin d’éffacer l’objet sélectionné. Ce bouton ne doit apparaitre que si on est en train de modifier un Objet de collection.
    • Ajoutez un bouton « Cancel » qui redirige l’utilisateur vers la page principale.

Correction

collection-detail.html
<header id="collection-header">
    <h1>{{selectedCollection()?.title}}</h1>
    <div>
        <app-search-bar 
            [(search)]="search"
        >
        </app-search-bar>
    </div>
</header>
<section class="collection-grid">
    @for (item of displayedItems(); track item.name) {
        @switch (item.rarity) {
            @case ("Legendary") {
                <div>
                    <app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
                    <hr class="gold">
                </div>
            }
            @case ("Rare") {
                <div>
                    <app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
                    <hr class="dashed">
                </div>
            }
            @default {
                <app-collection-item-card [item]="item" (click)="openItem(item)"></app-collection-item-card>
            }
        }
    } 
</section>
@let displayedItemsCount = displayedItems().length;
@if(displayedItemsCount > 0) {
    <div class="centered">{{displayedItemsCount}} objet(s) affiché(s).</div>
} @else {
    <div class="centered">Aucun résultat.</div>
}
<div class="centered">
    <button (click)="addItem()">Ajouter Objet</button>
</div>

collection-detail.ts
import { ChangeDetectionStrategy, Component, computed, inject, model, signal } from '@angular/core';
import { SearchBar } from "../../components/search-bar/search-bar";
import { Collection } from '../../models/collection';
import { CollectionService } from '../../services/collection-service';
import { CollectionItemCard } from '../../components/collection-item-card/collection-item-card';
import { CollectionItem } from '../../models/collection-item';
import { Router } from '@angular/router';

@Component({
  selector: 'app-collection-detail',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './collection-detail.html',
  styleUrl: './collection-detail.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionDetail {

  private readonly router = inject(Router);

  collectionService = inject(CollectionService);
  search = model('');

  selectedCollection = signal<Collection | null>(null);
  displayedItems = computed(() => {
    const allItems = this.selectedCollection()?.items || [];
    return allItems.filter(item => 
      item.name.toLowerCase().includes(
        (this.search() || '').toLocaleLowerCase()
      )
    );
  });
  
  constructor() {
    const allCollections = this.collectionService.getAll();
    if (allCollections.length > 0) {
      this.selectedCollection.set(allCollections[0]);
    }
  }

  addItem() {
    this.router.navigate(['item']);
  }

  openItem(item: CollectionItem) {
    this.router.navigate(['item', item.id]);
  }

}

collection-item-detail.html
<app-collection-item-card [item]="collectionItem()" />

<form [formGroup]="itemFormGroup" (submit)="submit($event)">
    <div class="form-field">
        <label for="name">Name : </label>
        <input id="name" name="name" formControlName="name"/>
        @if (isFieldValid('name')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="description">Description : </label>
        <textarea id="description" name="description" formControlName="description" rows="5"></textarea>    
        @if (isFieldValid('description')) {
            <div class="error">This field is required!</div>
        }
    </div>
    <div class="form-field">
        <label for="image">Image</label>
        <input id="image" name="image" type="file" (change)="onFileChange($event)">
        @if (isFieldValid('image')) {
            <div class="error">This field is required.</div>
        }
    </div>
    <div class="form-field">
        <label for="rarity">Rarity</label>
        <select id="rarity" name="rarity" formControlName="rarity">
        @for (rarity of rarities; track rarity) {
            <option [value]="rarity">{{rarity}}</option>
        }
        </select>
    </div>
    <div class="form-field">
        <label for="price">Price : </label>
        <input id="price" name="price" formControlName="price" type="number"/>
        @if (isFieldValid('price')) {
            @let priceFormControl = itemFormGroup.get('price');
            @if (priceFormControl?.hasError('required')) {
                <div class="error">This field is required!</div>
            }
            @if (priceFormControl?.hasError('min')) {
                <div class="error">The value must be bigger or equal to 0!</div>
            }
        }
    </div>
    <div>
        <button type="button" (click)="cancel()">Cancel</button>
        <button type="submit" 
            [disabled]="itemFormGroup.invalid"
        >
            Save
        </button>
        @if (this.itemId()) {
            <button type="button" (click)="deleteItem()">Delete</button>
        }
    </div>
</form>

collection-item-detail.ts
import { Component, effect, inject, input, linkedSignal, OnDestroy, OnInit, signal, } from '@angular/core';
import { Router } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { CollectionItem, Rarities } from '../../models/collection-item';
import { CollectionItemCard } from "../../components/collection-item-card/collection-item-card";
import { Subscription } from 'rxjs';
import { CollectionService } from '../../services/collection-service';
import { Collection } from '../../models/collection';

@Component({
  selector: 'app-collection-item-detail',
  imports: [ReactiveFormsModule, CollectionItemCard],
  templateUrl: './collection-item-detail.html',
  styleUrl: './collection-item-detail.scss'
})
export class CollectionItemDetail implements OnDestroy {

  private readonly fb = inject(FormBuilder);
  private readonly router = inject(Router);
  private readonly collectionService = inject(CollectionService);

  rarities = Object.values(Rarities);

  itemId = input<number | null, string | null>(null, {
    alias: 'id',
    transform: ((id: string | null) => id ? parseInt(id) : null)
  });

  selectedCollection!: Collection;
  collectionItem = signal<CollectionItem>(new CollectionItem());

  itemFormGroup = this.fb.group({
    name: ['', [Validators.required]],
    description: ['', [Validators.required]],
    image: ['', [Validators.required]],
    rarity: ['', [Validators.required]],
    price: [0, [Validators.required, Validators.min(0)]]
  });

  valueChangeSubscription: Subscription | null = null;

  constructor() {
    effect(() => {
      let itemToDisplay =  new CollectionItem();
      this.selectedCollection = this.collectionService.getAll()[0];
      if (this.itemId()) {
        const itemFound = this.selectedCollection.items.find(item => item.id === this.itemId());
        if (itemFound) {
          itemToDisplay = itemFound;
        } else {
          this.router.navigate(['not-found']);
        }
      }
      this.itemFormGroup.patchValue(itemToDisplay);
    });

    this.valueChangeSubscription = this.itemFormGroup.valueChanges.subscribe(_ => {
      this.collectionItem.set(Object.assign(new CollectionItem(), this.itemFormGroup.value));
    });
  }

  submit(event: Event) {
    event.preventDefault();

    const itemId = this.itemId();
    if (itemId) {
      this.collectionItem().id = itemId;
      this.collectionService.updateItem(this.selectedCollection, this.collectionItem());
    } else {
      this.collectionService.addItem(this.selectedCollection, this.collectionItem());
    }

    this.router.navigate(['/']);
  }

  deleteItem() {
    const itemId = this.itemId();
    if (itemId) {
      this.collectionService.deleteItem(this.selectedCollection.id, itemId);
    }
    this.router.navigate(['/']);
  }

  cancel() {
    this.router.navigate(['/']);
  }

  isFieldValid(fieldName: string) {
    const formControl = this.itemFormGroup.get(fieldName);
    return formControl?.invalid && (formControl?.dirty || formControl?.touched);
  }
  
  onFileChange(event: any) {
    const reader = new FileReader();
    if (event.target.files && event.target.files.length) {
      const [file] = event.target.files;
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.itemFormGroup.patchValue({
          image: reader.result as string
        });
      };
    }
  }

  ngOnDestroy(): void {
    this.valueChangeSubscription?.unsubscribe();
  }

}

Conclusion

L’application de gestion de collections commence à prendre forme et nous avons maintenant un formulaire de création d’objets fonctionnel. Dans le prochain chapitre, nous verrons comment rendre le tout plus beau, en utilisant Angular Material.

Laisser un commentaire