Retourner à : Angular 18 pour débutants
Lors du chapitre précédent de nos cours Angular, nous avons vu comment créer une application avec différentes routes qui affichent des composants différents à l’écran. Aujourd’hui nous allons voir comment créer des formulaires réactifs en Angular. Pour cela, on va créer un exemple de formulaire qui nous permettra de récupérer toutes les informations nécessaires afin de pouvoir créer une carte à jouer, tel qu’affichée à l’écran.
En plus de cela, nous verrons comment utiliser les informations de notre fichier afin d’afficher un aperçu de notre carte qui se met à jour avec chaque modification faite dans notre formulaire. Pour cela,
- on va commencer par expliquer ce que sont les « Reactive Forms » (ou « formulaires réactifs » en français) ;
- nous verrons ensuite comment créer un premier formulaire réactif en utilisant « FormControl » et « FormGroup » ;
- on verra comment utiliser des validateurs pour valider les entrées des utilisateur ;
- par la suite, on va parler de « FormBuilder » qui nous permettra de créer des formulaires plus simplement ;
- pour finir, on verra comment réagir à des changements de valeurs dans notre formulaire.
Pour rappel, cette série de vidéos s’inscrit dans une longue lignée de vidéos dont le fil rouge est la création d’une application de visualisation et gestion de cartes à collectionner de type Pokémon, Magic, Yu-Gi-Oh! ou autres.

Pour commencer, expliquons rapidement les différents types de formulaires que vous pouvez rencontrer sous Angular. Le premier type sont les « Template-driven Forms ». L’utilisation de ce type de formulaires consiste à utiliser les fonctionnalités vues dans les vidéos précédentes pour récupérer les valeurs des différents champs. Par exemple, on utilise le « two-way binding » avec la propriété « ngModel » pour synchroniser l’état du formulaire avec des variables dans notre fichier TypeScript.
Ce type de formulaires fonctionne, mais sous Angular, on a également un autre type de formulaires nommés « Reactive Forms », et qui permet de définir le comportement du formulaire de manière déclarative dans votre fichier TypeScript. En bref, on va pouvoir indiquer – dans notre fichier TypeScript – pour chaque champ de notre formulaire, sa valeur par défaut ainsi que les règles de validation qui devront être appliquées au champ. On pourra même souscrire aux changements de ses champs afin d’y réagir en temps réel si besoin.
Maintenant qu’on a vu la théorie, passons à la pratique. Ce que j’aimerais faire, ce serait de créer un formulaire qui nous permettra de créer un monstre. Pour cela, nous avons déjà un composant « MonsterComponent » qui est accessible à l’URL « /monster » et « /monster » suivi d’un identifiant.
Ouvrons le fichier « monster.component.html » et créons un premier formulaire basique où on aura seulement un input pour le nom de notre monstre:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text">
</div>
<button type="submit">Save</button>
</form>
Ajoutons un peu de « css » au fichier « monster.component.css »:
form {
max-width: 300px;
}
.form-field {
display: flex;
flex-direction: column;
margin: 10px 0;
}
label {
font-size: small;
}
Maintenant que nous avons un formulaire, regardons comment lier notre input à notre fichier TypScript. Ici, on aurait bien entendu pu utiliser « ngModel », mais le sujet d’aujourd’hui ce sont les formulaires réactifs. Du coup, on va utiliser un « FormControl ».
La classe « FormControl » 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, cette classe 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 cette classe « FormControl », on doit tout d’abord déclarer le module « ReactiveFormsModule » dans les 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 ». On va donc ouvrir notre fichier « monster.component.ts » et y créer un nouveau « FormControl » auquel on va passer en paramètre la valeur par défaut de notre input. Donc ici, ce sera une chaîne de caractères vide:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-monster',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './monster.component.html',
styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private routeSubscription: Subscription | null = null;
name = new FormControl('');
monsterId = -1;
ngOnInit(): void {
this.routeSubscription = this.route.params.subscribe(params => {
if (params['id']) {
this.monsterId = parseInt(params['id']);
}
});
}
ngOnDestroy(): void {
this.routeSubscription?.unsubscribe();
}
submit(event: Event) {
event.preventDefault();
console.log(this.name.value);
}
}
Maintenant qu’on a notre « FormControl », retournons dans notre fichier HTML et on va pouvoir le connecter à notre input en le passant via l’attribut « formControl »:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" [formControl]="name">
</div>
<button type="submit">Save</button>
</form>
Si on regarde maintenant le résultat dans notre navigateur, on voit que si on tape un texte dans notre input et qu’on clique sur le bouton “Save”, la console nous affiche bien la valeur de notre input:

Vous vous demandez peut-être comment vous pouvez modifier la valeur d’un input via votre code « TypeScript ». Pour cela, vous pouvez utiliser la méthode « setValue » de la classe « FormControl ». Pour illustrer cela, on peut ajouter un bouton à notre HTML qui va changer le contenu de notre input avec le mot “Changed”:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" [formControl]="name">
</div>
<button type="submit">Save</button>
<button (click)="name.setValue('Changed')">Set Name</button>
</form>
Là, comme vous pouvez le constater, si vous appuyez sur le bouton “Set Name”, la valeur de l’input est bien mise à jour.

Regardons comment modifier notre « FormControl » afin d’indiquer que notre input est obligatoire. Pour cela, on peut passer en tant que deuxième paramètre du constructeur de « FormControl », un tableau qui va contenir les règles de validation que l’input doit respecter. Angular vient avec une classe « Validators » qui possède plusieurs fonctions de validation, dont la fonction « required » qui peut être utilisé pour rendre un input obligatoire.
Ajoutons ce validateur à notre « FormControl »:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-monster',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './monster.component.html',
styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private routeSubscription: Subscription | null = null;
name = new FormControl('', [Validators.required]);
monsterId = -1;
ngOnInit(): void {
this.routeSubscription = this.route.params.subscribe(params => {
if (params['id']) {
this.monsterId = parseInt(params['id']);
}
});
}
ngOnDestroy(): void {
this.routeSubscription?.unsubscribe();
}
submit(event: Event) {
event.preventDefault();
console.log(this.name.value);
}
}
Modifions également le fichier « monster.component.html » afin de supprimer le bouton “Set Name”, car on n’en aura plus besoin, et profitons en pour activer le bouton “Save” uniquement si notre input est valide. Pour ce faire, nous pouvons utiliser la propriété « invalid » de la classe « FormControl »:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" [formControl]="name">
</div>
<button type="submit" [disabled]="name.invalid">Save</button>
</form>
En plus de cela, ce serait bien qu’on puisse changer la couleur de notre input s’il est en erreur, et on devrait afficher un message pour indiquer à l’utilisateur quelle est l’erreur exacte.
Pour la partie design, c’est assez simple, notre « FormControl » va ajouter des classes « css » à notre input selon son état. Entre autres, on aura droit à :
- 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.
Du coup on peut ajouter le « css » suivant à notre fichier « monster.component.css » afin de colorier les bords de notre input en rouge si jamais il n’est pas valide:
...
.ng-dirty.ng-invalid, .ng-touched.ng-invalid {
border-color: red;
border-width: 1px;
}
.error {
font-size: x-small;
color: red;
}
Dans notre fichier « css », nous avons aussi ajouter une nouvelle classe « error » qui sera utilisée afin d’afficher notre message d’erreur en rouge. Pour afficher ce message d’erreur, nous pouvons utiliser les attributs « invalid », « dirty » et « touched » de notre « FormControl » afin de déterminer si oui ou non notre message doit être affiché:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" [formControl]="name">
@if (name.invalid && (name.dirty || name.touched)) {
<div class="error">This field is required.</div>
}
</div>
<button type="submit" [disabled]="name.invalid">Save</button>
</form>
Ici nous vérifions que l’input a bien été manipulé par l’utilisateur, car si on ne fait pas ce contrôle, notre message d’erreur sera affiché dès le chargement du formulaire, étant donné que lors du chargement le champ “name” est vide.
Comme vous le voyez dans votre navigateur, si vous cliquez sur votre champ, mais le laissez vide, une erreur s’affiche indiquant que le champ est obligatoire.

Maintenant, on va faire exactement la même chose, mais pour un champ qu’on va appeler « hp », et qui va être un nombre entre 1 et 200 inclus. Le code va être exactement le même à l’exception près qu’on aura, en tant que valeur par défaut, la valeur 0 et qu’on va utiliser – en plus du validateur « required »- les validateurs « min » et « max » pour s’assurer que la valeur se situe dans le bon intervalle:
...
export class MonsterComponent implements OnInit, OnDestroy {
...
name = new FormControl('', [Validators.required]);
hp = new FormControl(0, [Validators.required, Validators.min(1), Validators.max(200)]);
...
submit(event: Event) {
event.preventDefault();
console.log(this.name.value);
console.log(this.hp.value);
}
}
On va modifier notre fichier HTML afin qu’il affiche un nouvel input de type number. En plus de cela on va aussi modifier notre bouton Save afin qu’il ne s’active que lorsque les deux inputs sont valides:
<form (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" [formControl]="name">
@if (name.invalid && (name.dirty || name.touched)) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="name">HP</label>
<input id="hp" name="hp" type="number" [formControl]="hp" />
@if (hp.invalid && (hp.dirty || hp.touched)) {
<div class="error">This field is not valid.</div>
}
</div>
<button type="submit" [disabled]="name.invalid || hp.invalid">Save</button>
</form>
On verra tout de suite comment créer des message spécifiques pour chaque erreur, mais attardons-nous d’abord sur le code en général. Comme vous le voyez, plus on aura de champs, plus on aura de « FormControls » et plus on devra gérer des états valides/invalides individuels, et on devra aussi ensuite gérer les différentes valeurs de manière individuelle en les lisant une à une à partir des « FormControls ».
Ça marche… mais ce n’est vraiment pas top… L’idéal aurait été de pouvoir les regrouper afin de pouvoir vérifier l’état de tout notre formulaire en un coup, et de pouvoir récupérer toutes les valeurs également en un seul appel. Eh bien, ça tombe bien, car Angular a une autre classe qui permet justement de faire ça, et c’est la classe « FormGroup » qu’on peut également importer de « @angular/forms ».
« FormGroups » prend un dictionnaire en tant que constructeur, dont les clés sont les noms de nos « FormControls » et les valeurs sont les objets « FormControl » en question.
Je propose donc de créer un « FormGroup » avec un « FormControl » par champ de notre modèle « monster »:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
...
export class MonsterComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private routeSubscription: Subscription | null = null;
formGroup = new FormGroup({
name: new FormControl('', [Validators.required]),
image: new FormControl('', [Validators.required]),
type: new FormControl(MonsterType.ELECTRIC, [Validators.required]),
hp: new FormControl(0, [Validators.required, Validators.min(1), Validators.max(200)]),
figureCaption: new FormControl('', [Validators.required]),
attackName: new FormControl('', [Validators.required]),
attackStrength: new FormControl(0, [Validators.required, Validators.min(1), Validators.max(200)]),
attackDescription: new FormControl('', [Validators.required])
});
monsterTypes = Object.values(MonsterType);
monsterId = -1;
ngOnInit(): void {
...
}
...
}
Là, nous allons devoir changer notre fonction « submit », afin d’y imprimer la propriété « value » de notre « FormGroup », qui va nous retourner un dictionnaire avec, en tant que clé, le nom des différents « FormControl », et en tant que valeur, la valeur que l’utilisateur aura entré dans l’input correspondant. On va également en profiter pour créer une fonction qui va prendre le nom d’un « FormControl » en paramètre et, retourner si oui ou non le champ est valide.
Pour cette fonction, on pourra accéder au « FormControl » avec le nom passé en paramètre en utilisant la méthode « get » du « FormGroup »:
...
export class MonsterComponent implements OnInit, OnDestroy {
...
submit(event: Event) {
event.preventDefault();
console.log(this.formGroup.value);
}
isFieldValid(fieldName: string) {
const formControl = this.formGroup.get(fieldName);
return formControl?.invalid && (formControl?.dirty || formControl?.touched);
}
}
Vous devons encore adapter notre fichier HTML afin d’y utiliser notre « FormGroup ». Pour cela, au niveau du « form » en lui-même, on va ajouter la propriété « formGroup » à laquelle on va assigner le « formGroup » qu’on vient de créer. Maintenant, au niveau des inputs, on va simplement renseigner le nom du « FormControl » correspondant via la propriété « formControlName » (et non pas via « formControl » comme avant):
<form [formGroup]="formGroup" (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" formControlName="name">
@if (isFieldValid('name')) {
<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="type">Type</label>
<select id="type" name="type" formControlName="type">
@for (type of monsterTypes; track type) {
<option [value]="type">{{type}}</option>
}
</select>
</div>
<div class="form-field">
<label for="hp">HP</label>
<input id="hp" name="hp" type="number" formControlName="hp">
@if (isFieldValid('hp')) {
<div class="error">This field is not valid.</div>
}
</div>
<div class="form-field">
<label for="figureCaption">Figure caption</label>
<input id="figureCaption" name="figureCaption" type="text" formControlName="figureCaption">
@if (isFieldValid('figureCaption')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackName">Attack name</label>
<input id="attackName" name="attackName" type="text" formControlName="attackName">
@if (isFieldValid('attackName')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackStrength">Attack strength</label>
<input id="attackStrength" name="attackStrength" type="number" formControlName="attackStrength">
@if (isFieldValid('attackStrength')) {
<div class="error">This field is not valid.</div>
}
</div>
<div class="form-field">
<label for="attackDescription">Attack Description</label>
<input id="attackDescription" name="attackDescription" type="text" formControlName="attackDescription">
@if (isFieldValid('attackDescription')) {
<div class="error">This field is required.</div>
}
</div>
<button type="submit" [disabled]="formGroup.invalid">Save</button>
</form>
En ce qui concerne notre input destiné au chargement de l’image de nos monstres, on ne va pas lui associer un « formControlName », car ce que nous voulons soumettre via notre « FormControl », ce ne sera pas le fichier en lui-même, mais l’encodage « base64 » de l’image qu’on pourra ensuite afficher directement dans notre HTML.
Pour cela, on va devoir créer une fonction “onFileChange” qui se chargera de récupérer l’image et de la convertir en « base64 », puis de la stocker dans notre « FormControl » en utilisant la méthode « patchValue » qui prend un dictionnaire avec les noms des « FormControls » à changer et la valeur qu’on souhaite leur assigner:
...
export class MonsterComponent implements OnInit, OnDestroy {
...
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.formGroup.patchValue({
image: reader.result as string
});
};
}
}
}
Maintenant qu’on a un formulaire complet avec tous les champs qui nous intéressent, essayons de l’améliorer. Pour commencer, ce serait bien d’afficher des messages d’erreurs plus parlants pour nos champs nombre. Il faudrait qu’on sache détecter quel validateur est en erreur. Pour cela, nous pouvons utiliser la méthode « hasError » du « FormControl » en question, en lui passant en paramètre le nom du validateur auquel on souhaite réagir:
<form [formGroup]="formGroup" (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" formControlName="name">
@if (isFieldValid('name')) {
<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="type">Type</label>
<select id="type" name="type" formControlName="type">
@for (type of monsterTypes; track type) {
<option [value]="type">{{type}}</option>
}
</select>
</div>
<div class="form-field">
<label for="hp">HP</label>
<input id="hp" name="hp" type="number" formControlName="hp">
@if (isFieldValid('hp')) {
@if (formGroup.get('hp')?.hasError('required')) {
<div class="error">A valid number is required.</div>
}
@if (formGroup.get('hp')?.hasError('min')) {
<div class="error">This field needs to be bigger than 0.</div>
}
@if (formGroup.get('hp')?.hasError('max')) {
<div class="error">This field needs to be smaller or equal to 200.</div>
}
}
</div>
<div class="form-field">
<label for="figureCaption">Figure caption</label>
<input id="figureCaption" name="figureCaption" type="text" formControlName="figureCaption">
@if (isFieldValid('figureCaption')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackName">Attack name</label>
<input id="attackName" name="attackName" type="text" formControlName="attackName">
@if (isFieldValid('attackName')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackStrength">Attack strength</label>
<input id="attackStrength" name="attackStrength" type="number" formControlName="attackStrength">
@if (isFieldValid('attackStrength')) {
@if (formGroup.get('attackStrength')?.hasError('required')) {
<div class="error">A valid number is required.</div>
}
@if (formGroup.get('attackStrength')?.hasError('min')) {
<div class="error">This field needs to be bigger than 0.</div>
}
@if (formGroup.get('attackStrength')?.hasError('max')) {
<div class="error">This field needs to be smaller or equal to 200.</div>
}
}
</div>
<div class="form-field">
<label for="attackDescription">Attack Description</label>
<input id="attackDescription" name="attackDescription" type="text" formControlName="attackDescription">
@if (isFieldValid('attackDescription')) {
<div class="error">This field is required.</div>
}
</div>
<button type="submit" [disabled]="formGroup.invalid">Save</button>
</form>
Et si on retourne dans notre navigateur on voit que notre formulaire fonctionne bien comme prévu:

Cette fois-ci, notre formulaire est bien prêt, mais il faut dire que notre fichier « TypeScript » est quand même encore un peu lourd avec tous ces « new FormControl » qu’on déclare dans notre « FormGroup ».
Là encore, Angular vient avec une réponse à ce problème en nous mettant à disposition le service « FormBuilder », qui doit lui aussi être importer de « @angular/forms ». Ce service 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 « FormControls » à 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 un tableaux de validateur.
A noter que si vous n’avez pas de validations à faire sur un champ, vous pouvez omettre ce tableau et ne renseigner que la valeur par défaut à utiliser dans le « FormControl ».
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ée:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
...
export class MonsterComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private routeSubscription: Subscription | null = null;
private fb = inject(FormBuilder);
formGroup = this.fb.group({
name: ['', [Validators.required]],
image: ['', [Validators.required]],
type: [MonsterType.ELECTRIC, [Validators.required]],
hp: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
figureCaption: ['', [Validators.required]],
attackName: ['', [Validators.required]],
attackStrength: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
attackDescription: ['', [Validators.required]]
});
monsterTypes = Object.values(MonsterType);
monsterId = -1;
ngOnInit(): void {
....
}
Comme vous pouvez le voir dans votre navigateur, notre formulaire fonctionne comme avant, et on va pouvoir regarder comment réagir aux changements du formulaire afin de créer une pré-visualisation de notre carte.
Tout d’abord, adaptons notre HTML afin d’y afficher un aperçu de notre carte, et ajoutons également un bouton “Back” qui nous permettra de revenir à notre liste de monstres. On va aussi en profiter pour implémenter la méthode « submit » afin que celle-ci, sauvegarde les informations du monstre actuel en utilisant le « MonsterService » et renvoie l’utilisateur à la liste de montres.
<div class="preview">
<app-playing-card [monster]="monster" />
</div>
<div class="main">
<form [formGroup]="formGroup" (submit)="submit($event)">
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" formControlName="name">
@if (isFieldValid('name')) {
<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="type">Type</label>
<select id="type" name="type" formControlName="type">
@for (type of monsterTypes; track type) {
<option [value]="type">{{type}}</option>
}
</select>
</div>
<div class="form-field">
<label for="hp">HP</label>
<input id="hp" name="hp" type="number" formControlName="hp">
@if (isFieldValid('hp')) {
@if (formGroup.get('hp')?.hasError('required')) {
<div class="error">A valid number is required.</div>
}
@if (formGroup.get('hp')?.hasError('min')) {
<div class="error">This field needs to be bigger than 0.</div>
}
@if (formGroup.get('hp')?.hasError('max')) {
<div class="error">This field needs to be smaller or equal to 200.</div>
}
}
</div>
<div class="form-field">
<label for="figureCaption">Figure caption</label>
<input id="figureCaption" name="figureCaption" type="text" formControlName="figureCaption">
@if (isFieldValid('figureCaption')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackName">Attack name</label>
<input id="attackName" name="attackName" type="text" formControlName="attackName">
@if (isFieldValid('attackName')) {
<div class="error">This field is required.</div>
}
</div>
<div class="form-field">
<label for="attackStrength">Attack strength</label>
<input id="attackStrength" name="attackStrength" type="number" formControlName="attackStrength">
@if (isFieldValid('attackStrength')) {
@if (formGroup.get('attackStrength')?.hasError('required')) {
<div class="error">A valid number is required.</div>
}
@if (formGroup.get('attackStrength')?.hasError('min')) {
<div class="error">This field needs to be bigger than 0.</div>
}
@if (formGroup.get('attackStrength')?.hasError('max')) {
<div class="error">This field needs to be smaller or equal to 200.</div>
}
}
</div>
<div class="form-field">
<label for="attackDescription">Attack Description</label>
<input id="attackDescription" name="attackDescription" type="text" formControlName="attackDescription">
@if (isFieldValid('attackDescription')) {
<div class="error">This field is required.</div>
}
</div>
<div class="button-container">
<button (click)="navigateBack()">Cancel</button>
<button type="submit" [disabled]="formGroup.invalid">Save</button>
</div>
</form>
</div>
Et dans le fichier « monster.component.ts » on adapte le code comme suit:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
import { Monster } from '../../models/monster.model';
import { PlayingCardComponent } from '../../components/playing-card/playing-card.component';
import { MonsterService } from '../../services/monster/monster.service';
...
export class MonsterComponent implements OnInit, OnDestroy {
...
private monsterService = inject(MonsterService);
...
navigateBack() {
this.router.navigate(['/home']);
}
submit(event: Event) {
event.preventDefault();
if (this.monsterId === -1) {
this.monsterService.add(this.monster);
} else {
this.monster.id = this.monsterId;
this.monsterService.update(this.monster);
}
this.navigateBack();
}
...
}
Adaptons notre « css » afin que les boutons soient un peu espacés et afin que notre carte se situe à gauche de notre formulaire:
.preview, .main {
display: inline-block;
vertical-align: top;
}
.preview {
width: 300px;
margin: 20px;
}
.main {
width: calc(100% - 340px);
}
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;
}
.button-container {
display: flex;
gap: 10px;
}
Regardons comment nous abonner aux changements de notre formulaire et comment mettre à jour en temps réel la carte monstre de pré-visualisation.
Pour cela, on va pouvoir utiliser la propriété « valueChanges » de notre « FormGroup », qui est un « Observable » auquel on va pouvoir s’abonner grâce à la méthode « subscribe ». Ensuite, on pourra récupérer ces données et les utiliser pour créer notre objet « monster » qu’on passe en paramètre de notre composant « playing-card »:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
import { Monster } from '../../models/monster.model';
import { PlayingCardComponent } from '../../components/playing-card/playing-card.component';
import { MonsterService } from '../../services/monster/monster.service';
@Component({
selector: 'app-monster',
standalone: true,
imports: [ReactiveFormsModule, PlayingCardComponent],
templateUrl: './monster.component.html',
styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private fb = inject(FormBuilder);
private router = inject(Router);
private monsterService = inject(MonsterService);
private routeSubscription: Subscription | null = null;
private formValuesSubscription: Subscription | null = null;
formGroup = this.fb.group({
name: ['', [Validators.required]],
image: ['', [Validators.required]],
type: [MonsterType.ELECTRIC, [Validators.required]],
hp: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
figureCaption: ['', [Validators.required]],
attackName: ['', [Validators.required]],
attackStrength: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
attackDescription: ['', [Validators.required]]
});
monsterTypes = Object.values(MonsterType);
monster: Monster = Object.assign(new Monster(), this.formGroup.value);
monsterId = -1;
ngOnInit(): void {
this.formValuesSubscription = this.formGroup.valueChanges.subscribe(data => {
this.monster = Object.assign(new Monster(), data);
});
this.routeSubscription = this.route.params.subscribe(params => {
if (params['id']) {
this.monsterId = parseInt(params['id']);
}
});
}
navigateBack() {
this.router.navigate(['/home']);
}
submit(event: Event) {
event.preventDefault();
if (this.monsterId === -1) {
this.monsterService.add(this.monster);
} else {
this.monster.id = this.monsterId;
this.monsterService.update(this.monster);
}
this.navigateBack();
}
isFieldValid(fieldName: string) {
const formControl = this.formGroup.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.formGroup.patchValue({
image: reader.result as string
});
};
}
}
ngOnDestroy(): void {
this.formValuesSubscription?.unsubscribe();
this.routeSubscription?.unsubscribe();
}
}
Comme vous pouvez le voir dans le navigateur, maintenant chaque changement dans notre formulaire est reflété dans notre pré-visualisation de la carte. Il ne nous reste plus qu’à trouver un moyen de charger un monstre existant. Pour le cas où notre utilisateur souhaite modifier un monstre, il faut aussi qu’on puisse sauvegarder le monstre ajouté ou modifié afin de l’afficher dans notre liste de monstres. Pour cela, on va réutiliser un service que nous avons créé dans nos vidéos précédentes, le service « MonsterService ».
On va commencer par vérifier si l’URL que l’utilisateur a appelé dans son navigateur contient l’identifiant d’un monstre. Si c’est le cas, on va utiliser la méthode « get » de notre service pour lire le monstre à afficher, et si on trouve un monstre avec l’identifiant en question, on le charge dans notre formulaire, ici aussi, en utilisant la méthode « patchValue » de notre « FormGroup ».
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Et voilà, notre page d’édition et de création de monstres est maintenant complétée. Retournons maintenant dans notre composant « monster-list.component.ts » et « monster-list-component.html » afin d’adapter le bouton « Add generic monster » afin qu’il redirige l’utilisateur vers la page de création d’un nouveau monstre. Et pour finir on va faire en sorte qu’un click sur une carte, l’ouvre dans notre éditeur de monstres.
Tout d’abord changeons le nom du button « Add generic monster » en « Add monster » et changeons le nom de la méthode « addGenericMonster » en « addMonster » :
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Maintenant adaptons le fichier TypeScript afin d’implémenter la méthode « addMonster » qui doit donc rediriger l’utilisateur vers la page de création d’un monstre en utilisant le « Router » qu’on va importer de « @angular/router » et injecter dans notre composant :
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Ajoutons maintenant encore un évènement « click » à nos cartes qui exécutera une méthode « openMonster » qu’on va ajouter dans un instant à notre fichier TypeScript :
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Une fois la méthode « openMonster » implémentée, notre fichier TypeScript ressemble à ceci :
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Adaptons encore le fichier css afin que notre souris passe en mode « pointer » quand on survole une carte :
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.centered {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
}
Et voilà, notre code est maintenant complet. On peut créer de nouveaux monstres où encore ouvrir et modifier des monstres existants.

Resources
Vous retrouvez ci-dessous un lien vers le code source utilisé dans ce chapitre. Ce lien est uniquement disponible pour les abonnés Standard et Premium.
Quiz
Répondez au quiz ci-dessous afin de vérifier que vous avez bien compris le contenu de cette leçon. Les quiz sont uniquement disponibles pour les abonnés Standard et Premium.