Espace apprenant · Angular 22 · PhpStorm
Espace apprenant Angular
Pour réviser et s’entraîner chapitre par chapitre : le vocabulaire à retenir, tous les exercices et un QCM.
- 47 chapitres
- 60 exercices
- Un glossaire par chapitre
- QCM corrigés instantanément
Démarrer : préparer son environnementAngular 22 · PhpStorm
Vous construisez une seule application, Médiathèque, dans votre propre projet, créé par vos soins. Aucun dépôt à cloner : les fichiers de départ sont donnés dans le cours.
- Vérifier Node.js
Vous utilisez PhpStorm, déjà installé. Il vous faut Node.js en version 22.22 ou plus (nodejs.org, version LTS). Pour vérifier :
node -v. Git (git-scm.com/downloads) est conseillé pour sauvegarder votre travail, mais il n’est pas obligatoire. - Créer vos projets
Au chapitre 2, vous créez vous-même votre projet
mediathequeavecng new mediatheque. C’est votre application, que vous construisez pas à pas. Vous créez aussi un projetdemo, un bac à sable pour essayer les exemples de théorie. Les deux vivent dans un dossier à part. Il n’y a rien à télécharger : le chapitre 2 vous guide. - Récupérer les fichiers de départ
Le cours est un tutoriel : tout le code et tous les fichiers de départ sont donnés dans le cours (CSS, données de départ,
db.json), à l’étape où vous en avez besoin, avec un bouton « Copier ». Les exercices à compléter affichent leur code de départ dans le bloc « Votre code à compléter ». - Lancer l’application
Dans le terminal de PhpStorm, à la racine de
mediatheque:ng serve, puis ouvrez http://localhost:4200 dans votre navigateur. Chaque modification enregistrée s’affiche tout de suite. Pour arrêter : Ctrl+C dans le terminal. - Sauvegarder votre travail (conseillé)
Dans votre projet
mediatheque, activez Git une fois : VCS > Create Git Repository…. Ensuite, de temps en temps : Git > Commit… (Ctrl+K), cochez vos fichiers, écrivez un court message (« étape 2 : les cartes »), puis Commit. Vous pourrez ainsi revenir en arrière si une étape tourne mal.
Niveau
Formation débutante
Les fondations d’Angular, pas à pas, pour un développeur back-end PHP.
Partie 1 · 1 / 19
Les fondations
Chapitre 1 · Les fondations
Angular et son écosystème
Comprendre ce qu'est Angular, pourquoi il est organisé en composants, et quelles briques vous allez utiliser pendant la formation.
Glossaire du chapitre
- Angular
- Framework front-end de Google : il construit l’interface qui s’exécute dans le navigateur.
- Framework
- Ensemble de règles et d’outils qui impose une structure à l’application.
- Composant
- Brique de base d’une interface : un template HTML, un style CSS et une classe TypeScript.
- SPA
- Single Page Application : une application à page unique, mise à jour sans recharger la page.
- CLI
- Interface en ligne de commande. Pour Angular, c’est la commande
ng. - TypeScript
- JavaScript avec des types. Il est transformé en JavaScript avant d’être exécuté par le navigateur.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 1
Où s'exécute une application Angular ?
Angular est un framework front-end : le code est envoyé au navigateur, qui l'exécute.
Quel est le nom de l'unité de base d'une interface Angular ?
Un composant regroupe une structure HTML, un style CSS et une logique TypeScript. Les composants s'imbriquent pour former l'application.
Quelle version d'Angular utilise cette formation ?
AngularJS (1.x) est l'ancienne version. Angular 2 et les suivantes repartent sur une base différente.
Que fait le CLI Angular (commande
ng) ?C'est l'outil en ligne de commande qui automatise les tâches courantes du projet.
Quelle syntaxe est aujourd'hui recommandée pour une condition dans un template ?
Le contrôle de flux
@ifest intégré au langage des templates Angular depuis la version 17.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 2 · Les fondations
Installation de l'environnement
Installer Node.js et Angular CLI, créer un premier projet, le lancer dans le navigateur, et configurer PhpStorm pour travailler confortablement.
Glossaire du chapitre
- Node.js
- Environnement qui exécute du JavaScript hors du navigateur. Angular CLI en a besoin.
- npm
- Gestionnaire de paquets de Node.js, l’équivalent de Composer pour PHP.
- Paquet (package)
- Bibliothèque installable avec npm, par exemple
@angular/core. - package.json
- Fiche d’identité du projet : son nom, ses dépendances et ses scripts (
npm start). - node_modules
- Dossier où sont téléchargées les bibliothèques. On ne le versionne jamais.
- Angular CLI
- Outil en ligne de commande (
ng) qui crée, lance, construit et teste un projet.
Lien avec le PHP
Dans l’écosystème PHP, npm joue le rôle de composer, et la commande ng ressemble à php bin/console de Symfony ou à php artisan de Laravel : un outil qui génère, lance et teste le projet.
2.1 Le parcours dans l'ordre, et Node.js
Ce chapitre se fait dans cet ordre. Chaque pas dépend du précédent : ne sautez aucun pas.
| Étape | Où | Action |
|---|---|---|
| 1 | Navigateur | Télécharger et installer Node.js |
| 2 | PhpStorm | Ouvrir PhpStorm, puis New Project (projet vide) |
| 3 | PhpStorm | Ouvrir le terminal intégré |
| 4 | Terminal | Vérifier Node.js, puis installer l'Angular CLI |
| 5 | Terminal | Créer le projet avec ng new |
| 6 | Terminal | Entrer dans le dossier du projet avec cd |
| 7 | Terminal | Lancer le serveur avec ng serve |
| 8 | Navigateur | Ouvrir http://localhost:4200 |
Pas 1 · Installer Node.js. Angular a besoin de Node.js, un environnement qui fait tourner du JavaScript en dehors du navigateur. Il sert ici à lancer les outils de développement (compilation, serveur local, tests). Node.js ne remplace donc pas votre serveur Apache ou Nginx.
Node.js livre aussi npm (Node Package Manager), l'équivalent de Composer pour les paquets JavaScript. Les dépendances sont décrites dans package.json, comme dans composer.json.
- Ouvrez nodejs.org dans votre navigateur.
- Téléchargez la version LTS (Long Term Support) de Node.js, en version 24 (par exemple 24.x). N'installez pas la version « Current » : une version impaire comme 25 n'est pas supportée par Angular.
- Lancez l'installeur et gardez les options par défaut.
- Fermez puis rouvrez PhpStorm s'il était déjà ouvert : il doit détecter la nouvelle installation.
2.2 PhpStorm : un projet vide et le terminal
Pas 2 · Ouvrir PhpStorm et créer un projet vide. Ce projet est simplement un dossier de travail : il contiendra vos projets Angular.
- Ouvrez PhpStorm.
- Sur l'écran d'accueil, cliquez sur New Project. (Si un projet est déjà ouvert : menu File → New Project.)
- Dans la liste de gauche, choisissez Empty Project.
- Dans Location, choisissez un dossier dans vos documents, par exemple
formation-angular. - Cliquez sur Create, puis sur This Window si PhpStorm le demande.
Pas 3 · Ouvrir le terminal de PhpStorm. Le terminal est intégré à l'IDE : il s'ouvre directement dans le dossier du projet.
- Menu View → Tool Windows → Terminal.
- Ou le raccourci :
Alt+F12(Windows et Linux),⌥+F12(macOS). - Un panneau apparaît en bas de l'écran, avec une invite de commande qui attend vos instructions.
2.3 Le terminal : Node.js, Angular CLI et ng new
Pas 4 · Vérifier Node.js, puis installer l'Angular CLI. Dans le terminal de PhpStorm :
node -v
npm -vChaque commande affiche un numéro de version. Si le terminal répond « command not found », Node.js n'est pas installé : reprenez le pas 1, puis fermez et rouvrez PhpStorm.
Quelle version de Node.js faut-il ? Angular 22 demande :
- Node 22.22.3 ou plus, ou
- Node 24.15 ou plus, ou
- Node 26.0 ou plus.
Avec une version plus ancienne, l'installation de l'Angular CLI peut échouer ou vous donner une version plus ancienne d'Angular. Si node -v affiche un numéro trop bas :
- installez la version LTS actuelle depuis nodejs.org ;
- fermez, puis rouvrez PhpStorm.
L'Angular CLI est l'outil en ligne de commande officiel. Il crée les projets, génère les composants, lance le serveur de développement et prépare la version finale. C'est l'équivalent de artisan pour Laravel.
npm install -g @angular/cli@22
cd ~
ng versionL'option -g installe l'outil globalement : il est utilisable depuis n'importe quel dossier. @22 impose la version 22 de la CLI (sans cela, vous pourriez obtenir une autre version). cd ~ vous place dans votre dossier personnel, hors de tout projet, et ng version affiche alors la version de la CLI globale.
Vérifier la version d'Angular. Dans le résultat de ng version, regardez la ligne Angular CLI. Elle doit commencer par 22, et Node.js doit afficher une version 24.x (ou 22.22.3 et plus).
| Si vous voyez | Ce que cela signifie | Que faire |
|---|---|---|
| Angular CLI 22.x et Node 24.x | Configuration attendue pour la formation. | Passez au pas 5. |
| Angular CLI 21.x ou plus ancien | Une ancienne CLI est installée, ou Node.js est trop ancien. | Installez Node 24 LTS, puis relancez npm install -g @angular/cli@22 et ng version depuis un dossier hors projet. |
| Un avertissement « version of Node not supported » | Node est en version impaire (25…) ou trop ancien. | Installez Node 24 LTS (voir ci-dessous si node -v n'a pas changé). |
node -v affiche une ancienne version (par exemple 14) dans un nouveau terminal | Deux installations de Node coexistent (par exemple Homebrew et nvm sur Mac) et l'ancienne passe en premier, ou, avec nvm, la version par défaut est restée l'ancienne (nvm use ne vaut que pour le terminal où on le tape). | Avec nvm : nvm use 24 puis nvm alias default 24. Sinon, lancez which -a node (macOS, Linux) ou where node (Windows) pour les lister, puis désinstallez l'ancienne. Fermez et rouvrez PhpStorm. |
ng : command not found | La CLI n'est pas installée, ou le terminal est resté ouvert pendant l'installation. | Fermez et rouvrez le terminal de PhpStorm, puis relancez ng version. |
Pas 5 · Créer le projet avec ng new. Toujours dans le terminal :
ng new mediathequeLe CLI pose peu de questions, et leur libellé peut varier légèrement d'une version à l'autre. Pour cette formation, répondez :
- Which stylesheet system would you like to use? : CSS (le plus simple pour commencer).
- Do you want to enable Server-Side Rendering (SSR)…? : No. Le rendu côté serveur sort du cadre de la formation.
- Si une question sur l'assistance par IA apparaît, vous pouvez la passer.
Deux choses sont déjà réglées pour vous :
- le routage n'est plus demandé : il est activé par défaut, et le projet est prêt pour naviguer entre des pages ;
- les projets récents sont créés sans Zone.js (« zoneless »), ce qui va de pair avec les signals vus dans la formation.
Si votre version pose une question sur le routage, répondez Yes.
2.4 Entrer dans le projet et le lancer avec ng serve
Pas 6 · Entrer dans le dossier du projet.
- Vous n'avez rien à rouvrir :
ng newa créé le dossiermediathequedans votre projet PhpStorm, et il apparaît tout seul dans l'arborescence à gauche (Project). - Le terminal, lui, est encore placé à la racine du projet vide.
Entrez dans le nouveau dossier :
cd mediathequeLe début de la ligne du terminal affiche maintenant mediatheque. Toutes les commandes ng suivantes se lancent depuis ce dossier.
Vérifier que le projet est bien en Angular 22. Toujours dans le dossier mediatheque :
ng versionLes lignes Angular CLI et Angular doivent toutes deux commencer par 22. Vous pouvez aussi ouvrir package.json : les paquets @angular/core et @angular/cli doivent indiquer ^22. Si vous voyez 21, la CLI globale n'était pas en 22 au moment du ng new : suivez l'astuce « mettre à jour un projet en 21 » du pas 4, puis revérifiez.
Pas 7 · Lancer le serveur de développement. Dans le terminal :
ng serveAttendez le message indiquant que l'application est prête, puis passez à l'étape suivante. Ne fermez pas le terminal : le serveur y tourne.
Pas 8 · Ouvrir l'application. Dans votre navigateur, allez sur http://localhost:4200. Le serveur recharge la page automatiquement à chaque sauvegarde de fichier : c'est le « hot reload ».
Pour arrêter le serveur, cliquez dans le terminal et faites Ctrl+C.
2.5 Régler PhpStorm pour Angular
PhpStorm gère nativement JavaScript et TypeScript. Quelques réglages rendent le travail plus confortable. Les libellés des menus peuvent légèrement varier selon la version de l'IDE.
- Interpréteur Node.js : Settings → Languages & Frameworks → JavaScript Runtime (selon la version de l'IDE, cette page peut aussi s'appeler Node.js). Vérifiez que l'installation de Node.js est bien détectée. Sinon, choisissez-la dans la liste. Les commandes tapées dans le terminal n'en ont pas besoin, mais l'IDE s'en sert pour le panneau npm et les configurations d'exécution.
- Support Angular : dans le même écran, ouvrez Languages & Frameworks → TypeScript → Angular. Si cette entrée est présente, le support Angular est actif : il apporte l'autocomplétion dans les templates. Sinon, allez dans Settings → Plugins, cherchez Angular et installez-le.
- Lancer le serveur depuis l'IDE : ouvrez
package.json, cliquez sur l'icône verte à côté de"start": cela lanceng serve. Vous pouvez aussi créer une configuration npm dans Run → Edit Configurations.
2.6 Un deuxième projet : le bac à sable demo
Le cours est organisé en deux projets, côte à côte dans votre dossier PhpStorm :
| Projet | Rôle | Quand l'utiliser |
|---|---|---|
mediatheque | L'application : la médiathèque que vous construisez de A à Z | Partie « Construire Médiathèque » de chaque chapitre |
demo | Un bac à sable pour essayer les exemples de théorie sans abîmer l'application | Partie « Comprendre et essayer » de chaque chapitre |
Chaque chapitre suit le même déroulé : une introduction (ce qu'on ajoute à Médiathèque et pourquoi), la théorie avec ses démos dans demo, puis le projet mediatheque pas à pas. Les QCM terminent le chapitre.
Créez maintenant le bac à sable. Dans le terminal, à la racine du projet PhpStorm (là où se trouve le dossier mediatheque), lancez :
ng new demoRépondez aux mêmes questions que pour mediatheque (CSS, No pour le rendu côté serveur). Entrez dans demo (cd demo) et lancez ng version : Angular CLI et Angular doivent aussi commencer par 22. Pour lancer les deux projets en même temps, démarrez demo sur un autre port :
cd demo
ng serve --port 4201mediatheque reste sur http://localhost:4200, demo est sur http://localhost:4201.
Étape 0 Médiathèque démarre
- Vérifiez que le projet
mediathequeest créé et queng serveest lancé. - Ouvrez
http://localhost:4200: vous voyez la page d'accueil par défaut d'Angular. - Ouvrez le fichier
src/app/app.tset remplacez tout son contenu : nous retirons ce dont nous n'avons pas besoin pour l'instant (le routeur reviendra au chapitre 16). - Ouvrez le fichier
src/app/app.htmlet remplacez tout son contenu par<h1>Médiathèque</h1>. - Sauvegardez et observez la page se mettre à jour toute seule.
Essayez d'abord seul à partir de la liste ci-dessus ; sinon suivez le code ci-dessous.
Le fichier src/app/app.ts doit contenir :
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {}Imports
Les imports de ce fichier. Chaque nom importé en haut du fichier vient d'une bibliothèque ou d'un fichier de l'application :
Component(de@angular/core) : le décorateur qui transforme une classe en composant (sélecteur, template, styles).
Le code
import { Component } from '@angular/core';On importe le décorateurComponent, sans lequel Angular ne reconnaît pas la classe comme un composant.@Component({ ... })Est posé juste avant la classe : il lui donne son identité.selector: 'app-root'est le nom de la balise HTML du composant,templateUrlindique le fichier HTML qui décrit son affichage,styleUrlle fichier CSS qui ne s'applique qu'à lui.export class App {}Est le composant lui-même. Il est vide pour l'instant : nous y ajouterons des données et des méthodes dans les étapes suivantes. Le motexportpermet aux autres fichiers de l'importer.
Le fichier src/app/app.html doit contenir uniquement :
<h1>Médiathèque</h1>Ce fichier est le template du composant : du HTML ordinaire. Nous remplaçons le contenu proposé par Angular par un simple titre, <h1>Médiathèque</h1>. Enregistrez : la page affiche ce titre sans que vous ayez rien à relancer.
Si la page se met à jour après votre sauvegarde sans rafraîchir manuellement le navigateur, l'environnement est prêt. Si la page ne charge pas, vérifiez dans le terminal le message d'erreur : le plus fréquent est un port 4200 déjà utilisé, à libérer ou à remplacer par ng serve --port 4300.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 2
Quel outil installe les paquets JavaScript d'un projet Angular ?
npm lit le fichier package.json et télécharge les dépendances. Composer est l'équivalent côté PHP.
Quelle commande crée un nouveau projet Angular ?
La commande
ng new nom-du-projetgénère l'arborescence complète du projet.Quelle est l'adresse habituelle du serveur de développement ?
Angular CLI utilise le port 4200 par défaut. Le port 8000 est plutôt celui de
php artisan serve.Que fait la fonctionnalité de « hot reload » ?
Le serveur détecte les modifications et rafraîchit l'application sans action manuelle.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 3 · Les fondations
Structure d'un projet Angular
Savoir ce que contient chaque fichier d'un projet généré par ng new, et repérer le point d'entrée de l'application.
Glossaire du chapitre
- src/app
- Dossier qui contient le code de votre application : composants, services, routes.
- src/main.ts
- Point de départ : il demande à Angular de démarrer l’application.
- angular.json
- Configuration du projet pour le CLI : dossiers, fichiers de build, options de test.
- tsconfig.json
- Réglages du compilateur TypeScript (niveau de rigueur, fichiers inclus).
- Bootstrap
- Démarrage de l’application, avec
bootstrapApplication. - Dépendance
- Bibliothèque dont le projet a besoin pour fonctionner.
Étape 1 La page d'accueil de Médiathèque
Ce qui est réalisé : Vous connaissez maintenant le rôle des fichiers. Deux d'entre eux servent pour l'apparence :
src/styles.csscontient les styles globaux, appliqués à toute l'application ;- le fichier
.cssd'un composant contient des styles limités à ce composant.
Fichiers concernés : src/styles.css · src/app/app.css · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 3
Dans quel dossier se trouve le code de l'application ?
Le code métier (composants, services, routes) est dans src/app/.
Pourquoi ne faut-il pas versionner node_modules ?
Le dossier se recrée à partir de package.json avec
npm install.Quel fichier démarre l'application ?
main.ts appelle bootstrapApplication avec le composant racine et la configuration.
Quel est le rôle de package.json ?
Il joue le même rôle que composer.json en PHP.
Code de Médiathèque à la fin du chapitre (3 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
/* ===== Kit CSS Médiathèque : styles globaux (à copier dans src/styles.css) ===== */
/* 1. Variables : les couleurs utilisées partout. Changez-les pour changer l'allure du site. */
:root {
--bg: #f7f7f8;
--surface: #ffffff;
--text: #222222;
--muted: #6b7280;
--line: #dddddd;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-soft: #e8efff;
--ok: #15803d;
--ok-soft: #e3f4e8;
--warn: #b45309;
--warn-soft: #fff1de;
--bad: #b91c1c;
--bad-soft: #fde8e8;
--radius: 8px;
--shadow: none;
--font: system-ui, sans-serif;
}
/* 2. Base */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; }
h1, h2, h3 { margin: 0 0 0.5rem; }
p { margin: 0 0 0.75rem; }
a { color: var(--primary); }
/* 3. Mise en page */
.container { max-width: 64rem; margin: 0 auto; padding: 0 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 1rem; }
.muted { color: var(--muted); }
/* 4. Boutons */
.btn { border: 0; border-radius: var(--radius); padding: 0.5rem 1rem; font: inherit; cursor: pointer; background: var(--primary); color: #fff; }
.btn:hover:not(:disabled) { background: var(--primary-dark); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--primary-soft); color: var(--primary); }
.btn-secondary:hover:not(:disabled) { background: var(--line); }
/* 5. Pastilles (badges) */
.badge { display: inline-block; padding: 0.1rem 0.6rem; border-radius: 999px; font-size: 0.8rem; background: var(--primary-soft); color: var(--primary); }
.badge-ok { background: var(--ok-soft); color: var(--ok); }
.badge-warn { background: var(--warn-soft); color: var(--warn); }
.badge-bad { background: var(--bad-soft); color: var(--bad); }
/* 6. Formulaires */
label { display: block; margin-bottom: 0.25rem; }
input, select, textarea { font: inherit; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.5rem 0.75rem; max-width: 100%; }
.field { margin-bottom: 1rem; }
.error { color: var(--bad); font-size: 0.875rem; margin-top: 0.25rem; }
/* 7. Cartes de formulaire et tableaux */
.form-card { max-width: 28rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.5rem; }
.form-card input:not([type="checkbox"]), .form-card select, .form-card textarea { width: 100%; }
.table { width: 100%; border-collapse: collapse; background: var(--surface); }
.table th, .table td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); }
.table th { background: var(--primary-soft); color: var(--primary); }/* En-tête */
.site-header { background: var(--surface); border-bottom: 1px solid var(--line); box-shadow: var(--shadow); }
.site-header__inner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 4rem; }
.logo { font-size: 1.25rem; font-weight: 800; color: var(--primary); text-decoration: none; }
.site-nav { display: flex; gap: 1.25rem; }
.site-nav a { color: var(--text); text-decoration: none; font-weight: 600; }
.site-nav a:hover { color: var(--primary); }
/* Bandeau d'accueil */
.hero { padding: 3rem 0 2rem; }
.hero h1 { font-size: clamp(1.75rem, 4vw, 2.75rem); }
.hero p { font-size: 1.1rem; color: var(--muted); max-width: 40rem; }
/* Pied de page */
.site-footer { margin-top: 3rem; padding: 1.5rem 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.9rem; text-align: center; }<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 4 · Les fondations
Les composants : template, style, logique
Créer un composant, comprendre ses trois fichiers, et l'utiliser dans un autre composant.
Glossaire du chapitre
- Template
- Partie HTML d’un composant, enrichie avec les syntaxes d’Angular.
- Sélecteur
- Nom de la balise HTML qui affiche le composant, par exemple
app-media-card. - Décorateur
- Annotation qui précède une classe, par exemple
@Component, pour lui donner un rôle. - Interpolation
- Affichage d’une valeur dans le template avec
{{ }}. - Standalone
- Composant autonome : il déclare lui-même ce dont il a besoin dans son tableau
imports. - Encapsulation
- Le style CSS d’un composant ne s’applique qu’à ce composant.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous découvrons les composants avec un exemple du carnet de recettes : la fiche d'un plat (dish-card). Rien de ce que vous créez ici n'est conservé : Médiathèque aura ensuite sa propre carte, construite dans la seconde partie du chapitre.
Au programme : 4.1 L'anatomie d'un composant · 4.2 Le décorateur @Component · 4.3 Créer un composant avec le CLI · 4.4 Utiliser un composant dans un autre
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
Étape 2 Les cartes de médias
Ce qui est réalisé : Dans cette étape :
- nous créons le premier composant de la médiathèque, la carte d'un média (titre, auteur, type, disponibilité) ;
- nous l'affichons trois fois sur la page d'accueil.
Fichiers concernés : src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/media-card/media-card.css · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
1 exercice
Exercice 4.5.A Un style à votre façonBonus
- Choisissez votre couleur dominante et modifiez
--primary,--primary-darket--primary-softdansstyles.css. - Copiez le prompt de la section 4.5 dans l'assistant IA de votre choix, en complétant les parties entre crochets (nom du composant, la même couleur, ambiance, contenu de
media-card.html). - Remplacez le contenu de
media-card.csspar le CSS obtenu. - Vérifiez dans le navigateur que les trois cartes s'affichent correctement, puis comparez avec le CSS du cours : lequel préférez-vous ?
- Si le résultat ne vous plaît pas, revenez au CSS du cours : les chapitres suivants fonctionnent avec les deux.
Voir la solution
Il n'y a pas de bonne réponse unique. Points à vérifier avec l'apprenant :
- le CSS reste dans
media-card.css; - il utilise les variables
var(--…); - le HTML n'a pas changé ;
- l'affichage reste correct quand on réduit la largeur de la fenêtre.
Si l'apprenant n'a pas accès à une IA, il garde le CSS du cours et passe à la suite.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 4
Quels sont les trois fichiers principaux d'un composant ?
Un composant regroupe sa logique, son HTML et son CSS.
À quoi sert le décorateur
@Component?C'est une annotation placée au-dessus de la classe pour la configurer.
Quel nom de classe le CLI produit-il pour le composant
media-carddans les projets récents ?Le suffixe Component n'est plus ajouté par défaut ; le fichier reste en kebab-case.
Pourquoi une erreur « is not a known element » apparaît-elle ?
Angular ne connaît pas la balise tant que le composant n'est pas importé.
Pour un composant, que signifie « encapsulation des styles » ?
Les styles déclarés dans un composant restent locaux, ce qui évite les conflits.
Code de Médiathèque à la fin du chapitre (6 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
title: string = 'Dune';
author: string = 'Frank Herbert';
type: 'livre' | 'film' | 'jeu' = 'livre';
available: boolean = true;
statusLabel(): string {
return this.available ? 'Disponible' : 'Indisponible';
}
}<article class="card">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ title }}</h2>
<p class="author">{{ author }}</p>
<div class="meta">
<span class="badge">{{ type }}</span>
<span class="badge badge-ok">{{ statusLabel() }}</span>
</div>
</article>/* Carte d'un média */
.card { display: flex; flex-direction: column; gap: 0.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
.card h2 { font-size: 1.1rem; margin: 0; }
.card .author { margin: 0; color: var(--muted); }
.card .meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: auto; }
.card--out { opacity: 0.6; }
.card--top { border-color: var(--primary); }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { MediaCard } from './media-card/media-card';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [MediaCard],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<section class="grid">
<app-media-card />
<app-media-card />
<app-media-card />
</section>
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Tu es développeur front-end. Écris le CSS du composant Angular « [NOM DU COMPOSANT] » de l'application Médiathèque (le site d'une médiathèque : livres, films, jeux).
Contraintes :
- CSS simple, sans framework ni bibliothèque.
- Utilise uniquement les variables déjà définies dans styles.css : --bg, --surface, --text, --muted, --line, --primary, --primary-dark, --primary-soft, --ok, --ok-soft, --warn, --warn-soft, --bad, --bad-soft, --radius, --shadow, --font.
- Les classes globales suivantes existent déjà, ne les redéfinis pas : .container, .grid, .btn, .btn-secondary, .badge, .badge-ok, .badge-warn, .badge-bad, .field, .error, .muted.
- Couleur dominante du site : [VALEUR À COMPLÉTER, EXEMPLE : bleu, vert, orange]. Le style doit s'accorder avec elle. N'écris jamais de couleur en dur : pour une nuance, utilise color-mix(in srgb, var(--primary) 20%, white) ou les variables existantes.
- Ambiance : [VALEUR À COMPLÉTER, EXEMPLE : sobre, chaleureuse, moderne].
- Style lisible ; l'interface doit rester correcte sur mobile (écriture mobile d'abord, puis @media (min-width: 40rem) si besoin).
- Ne modifie pas le HTML. Si une classe te manque, liste-la à part, à la fin de ta réponse.
- Réponds avec le CSS seul, dans un seul bloc de code, avec des commentaires courts en français.
Voici le HTML du composant :
[COLLER ICI LE TEMPLATE .html DU COMPOSANT]Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 2 · 2 / 19
TypeScript et réactivité
Chapitre 5 · TypeScript et réactivité
TypeScript : types, fonctions et tableaux
Lire et écrire les bases de TypeScript : variables, types, tableaux, objets et fonctions. Tout le code Angular repose là-dessus.
Glossaire du chapitre
- Type
- Nature d’une valeur : texte (
string), nombre (number), booléen, etc. - Annotation de type
- Indication écrite après un nom, par exemple
note: number. - Inférence
- TypeScript devine le type à partir de la valeur d’origine : l’annotation est alors facultative.
- const / let
const: variable qu’on ne réaffecte pas.let: variable réaffectable.- Fonction fléchée
- Écriture courte d’une fonction :
(x) => x * 2. - Template literal
- Chaîne avec des backticks et des
${}, comme les chaînes avec variables en PHP.
Comprendre et essayer (projet demo)
Ce qui sera montré : Pour voir ce que font les exemples de ce chapitre, il faut un endroit où les exécuter.
Au programme : 5.1 Un composant par démo : BasesTypescript · 5.2 Variables et types de base · 5.3 Tableaux et objets · 5.4 Fonctions et fonctions fléchées · 5.5 Syntaxes pratiques
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 5.5.A Échauffement : la note d'une recetteEssentiel
Dans le composant BasesTypescript (voir 5.1), écrivez dans le constructeur :
- Un objet
recetteavec untitre(texte), unedureeen minutes (nombre) et une propriéténote(nombre, facultatif). - Une fonction
formaterNote(note: number): stringqui renvoie la note avec une décimale suivie de/ 5(exemple :4.7 / 5). Utiliseznote.toFixed(1). - Une fonction
noteAffichee(r)qui renvoie la note formatée sinoteexiste, sinon le textePas encore notée. - Affichez dans la console :
Crêpes : 4.7 / 5.
Ouvrez la console du navigateur (touche F12) pour lire le résultat.
Voir la solution
constructor() {
const recette: { titre: string; duree: number; note?: number } = {
titre: 'Crêpes',
duree: 30,
note: 4.7,
};
function formaterNote(note: number): string {
return `${note.toFixed(1)} / 5`;
}
function noteAffichee(r: { note?: number }): string {
return r.note !== undefined ? formaterNote(r.note) : 'Pas encore notée';
}
console.log(`${recette.titre} : ${noteAffichee(recette)}`);
// Crêpes : 4.7 / 5
}Le point clé :
- on teste
note !== undefined, et nonif (r.note); - une note valant
0est une vraie note, maisif (0)est faux : elle serait affichée comme « Pas encore notée ».
Une écriture plus courte existe : r.note?.`toFixed(1)` ?? 'Pas encore notée' (chaînage optionnel, puis coalescence).
Testez-vous : QCM 5 questions✓
QCM · Chapitre 5
Que fait TypeScript avant l'exécution du code ?
Le compilateur TypeScript produit du JavaScript que le navigateur comprend.
Quelle différence entre const et let ?
Préférez const par défaut, et let seulement quand la valeur doit changer.
Que vaut '5' === 5 en TypeScript ?
Les types sont différents (texte et nombre), l'égalité stricte renvoie donc false.
Quel type écrit-on pour une liste de noms ?
string[] signifie « tableau de chaînes ».
Que renvoie
user?.adresse?.villesiadressen'existe pas ?Le chaînage optionnel s'arrête dès qu'une valeur est absente.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 6 · TypeScript et réactivité
TypeScript : interfaces, classes et asynchrone
Modéliser des données avec des interfaces, écrire des classes, organiser le code en modules et comprendre les promesses.
Glossaire du chapitre
- Interface
- Décrit la forme d’un objet : ses propriétés et leurs types. Elle disparaît à l’exécution.
- Union
- Un type qui accepte plusieurs possibilités, écrit avec
|, par exemplestring | null. - Classe
- Modèle d’objet avec des propriétés, un constructeur et des méthodes.
- Module
- Fichier qui exporte des éléments (
export) et les importe ailleurs (import). - Promise
- Valeur qui arrivera plus tard : le résultat d’une opération asynchrone.
- async / await
- Écriture d’un code asynchrone qui se lit presque comme un code synchrone.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans cette partie, nous continuons avec le carnet de recettes :
- dans un nouveau composant,
ObjetsTypescript; - avec un exemple volontairement différent de Médiathèque, pour comprendre sans toucher à l'application ;
- collez chaque exemple dans le constructeur d'
ObjetsTypescriptquand il s'agit de code à exécuter.
Au programme : 6.1 Les interfaces : décrire la forme des données · 6.2 Types union et types littéraux · 6.3 Les classes et le constructeur · 6.4 Organiser le code en modules · 6.5 Les promesses et async / await
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 6.5.A Des recettes bien typéesEssentiel
Dans le composant ObjetsTypescript :
- Déclarez une interface
Recette(titre,dureeen minutes,vegetarienne,notefacultative) et un typeDifficulte('facile' | 'moyenne' | 'difficile') ajouté à l'interface. - Créez un tableau de trois recettes.
- Écrivez
rapides(liste: Recette[], dureeMax: number): string[]qui renvoie les titres des recettes de durée inférieure ou égale àdureeMax(filterpuismap). - Écrivez
async function chargerRecettes(): Promise<Recette[]>qui attend 300 ms puis renvoie le tableau. - Appelez-les et affichez dans la console les titres des recettes de 30 minutes maximum.
Voir la solution
type Difficulte = 'facile' | 'moyenne' | 'difficile';
interface Recette {
titre: string;
duree: number;
vegetarienne: boolean;
difficulte: Difficulte;
note?: number;
}
const RECETTES: Recette[] = [
{ titre: 'Crêpes', duree: 30, vegetarienne: true, difficulte: 'facile' },
{ titre: 'Ratatouille', duree: 60, vegetarienne: true, difficulte: 'moyenne' },
{ titre: 'Bœuf bourguignon', duree: 180, vegetarienne: false, difficulte: 'difficile' },
];
function rapides(liste: Recette[], dureeMax: number): string[] {
return liste.filter((r) => r.duree <= dureeMax).map((r) => r.titre);
}
async function chargerRecettes(): Promise<Recette[]> {
// await : attend le résultat de l’opération asynchrone
await new Promise((resolve) => setTimeout(resolve, 300));
return RECETTES;
}
chargerRecettes().then((liste) => console.log(rapides(liste, 30))); // ['Crêpes']filter garde les éléments qui respectent la condition, map transforme chacun d'eux : les deux renvoient un nouveau tableau et laissent l'original intact.
Étape 3 Le modèle de données
Ce qui est réalisé : Dans cette étape, nous créons trois éléments dans le projet mediatheque :
- le type
Media(etMediaType) qui décrit un média de la médiathèque ; - la classe
Loanqui représente un emprunt : on y ajoute des médias, on les compte et on calcule le délai de retour ; - la fonction
chargerMedias()qui simule la réponse d'un serveur.
Fichiers concernés : src/app/models/media.ts · src/app/data/medias.ts · src/app/models/loan.ts · src/app/app.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 6
À quoi sert une interface TypeScript ?
Une interface ne produit aucun code : elle décrit une forme de données.
Que signifie le point d'interrogation dans
available?: boolean?Le ? indique que la propriété peut être absente de l'objet.
Quel type choisir pour une valeur dont on ne connaît pas encore le type, en évitant any ?
unknown oblige à vérifier le type avant de l'utiliser, contrairement à any.
Que fait
publicdevant un paramètre de constructeur ?C'est la promotion de propriétés, identique à PHP 8.
Que renvoie une fonction
async?Une fonction async renvoie toujours une Promise, même si elle renvoie une valeur simple.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
export type MediaType = 'livre' | 'film' | 'jeu';
// Interface : décrit la forme d’un objet ; elle disparaît à l’exécution
export interface Media {
id: number;
title: string;
author: string;
year: number;
type: MediaType;
genre: string;
available: boolean;
rating?: number;
loanDays: number;
}
export const MAX_LOANS = 5;import { Media } from '../models/media';
export const MEDIAS: Media[] = [
{ id: 1, title: 'Dune', author: 'Frank Herbert', year: 1965, type: 'livre', genre: 'Science-fiction', available: true, rating: 4.7, loanDays: 21 },
{ id: 2, title: 'Le Petit Prince', author: 'Antoine de Saint-Exupéry', year: 1943, type: 'livre', genre: 'Conte', available: true, rating: 4.8, loanDays: 21 },
{ id: 3, title: 'L\'Étranger', author: 'Albert Camus', year: 1942, type: 'livre', genre: 'Roman', available: false, rating: 4.2, loanDays: 21 },
{ id: 4, title: 'Fondation', author: 'Isaac Asimov', year: 1951, type: 'livre', genre: 'Science-fiction', available: true, loanDays: 21 },
{ id: 5, title: 'Blade Runner', author: 'Ridley Scott', year: 1982, type: 'film', genre: 'Science-fiction', available: true, rating: 4.5, loanDays: 7 },
{ id: 6, title: 'Le Voyage de Chihiro', author: 'Hayao Miyazaki', year: 2001, type: 'film', genre: 'Animation', available: true, rating: 4.9, loanDays: 7 },
{ id: 7, title: 'Les Sept Samouraïs', author: 'Akira Kurosawa', year: 1954, type: 'film', genre: 'Aventure', available: false, rating: 4.8, loanDays: 7 },
{ id: 8, title: 'Le Fabuleux Destin d\'Amélie Poulain', author: 'Jean-Pierre Jeunet', year: 2001, type: 'film', genre: 'Comédie', available: true, rating: 4.3, loanDays: 7 },
{ id: 9, title: 'Hollow Knight', author: 'Team Cherry', year: 2017, type: 'jeu', genre: 'Aventure', available: true, rating: 4.6, loanDays: 14 },
{ id: 10, title: 'Celeste', author: 'Maddy Makes Games', year: 2018, type: 'jeu', genre: 'Plateforme', available: true, rating: 4.4, loanDays: 14 },
{ id: 11, title: 'Portal 2', author: 'Valve', year: 2011, type: 'jeu', genre: 'Puzzle', available: false, rating: 4.7, loanDays: 14 },
{ id: 12, title: 'Stardew Valley', author: 'ConcernedApe', year: 2016, type: 'jeu', genre: 'Simulation', available: true, loanDays: 14 },
];
export async function chargerMedias(): Promise<Media[]> {
// await : attend le résultat de l’opération asynchrone
await new Promise((resolve) => setTimeout(resolve, 500));
return MEDIAS;
}import { Media } from './media';
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Loan {
private medias: Media[] = [];
add(media: Media): void {
this.medias.push(media);
}
count(): number {
return this.medias.length;
}
returnInDays(): number {
return this.medias.reduce((min, m) => Math.min(min, m.loanDays), Infinity);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { chargerMedias } from './data/medias';
import { MediaCard } from './media-card/media-card';
import { Loan } from './models/loan';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [MediaCard],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
constructor() {
chargerMedias().then((medias) => {
console.log(medias.filter((m) => m.available).length);
const loan = new Loan();
loan.add(medias[0]); // Dune : 21 jours
loan.add(medias[4]); // Blade Runner : 7 jours
loan.add(medias[9]); // Celeste : 14 jours
console.log(loan.count(), loan.returnInDays());
});
}
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 7 · TypeScript et réactivité
Le data binding
Relier les données de la classe au template (affichage), et les actions de l'utilisateur à la classe (événements).
Glossaire du chapitre
- Data binding
- Liaison entre la classe et le template : une valeur s’affiche, ou une action remonte.
- Property binding
- Envoi d’une valeur de la classe vers un attribut :
[disabled]="estBloque". - Event binding
- Réaction à une action de l’utilisateur :
(click)="ajouter()". - Two-way binding
- Liaison à double sens :
[(ngModel)]met à jour la classe et le champ. - Référence de template
- Nom donné à un élément du template avec
#nom.
Comprendre et essayer (projet demo)
Ce qui sera montré : Nous continuons avec le carnet de recettes dans un nouveau composant, Binding. Ses propriétés servent d'exemples :
titre,chef,duree,vegetarienne;- essayez chaque extrait dans
binding.tsetbinding.html.
Au programme : 7.1 L'interpolation : afficher une donnée · 7.2 Le property binding : piloter une propriété · 7.3 L'event binding : réagir à une action · 7.4 Le binding bidirectionnel avec ngModel
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 7.4.A Un champ qui s'effaceEssentiel
Dans le composant Binding :
- Ajoutez un champ de saisie lié par
ngModelà une propriétérecherche(n'oubliez pasFormsModule). - Affichez sous le champ le texte
Vous cherchez : …avec la valeur saisie. - Ajoutez un bouton « Effacer » qui vide la recherche. Il doit être désactivé tant que le champ est vide (utilisez
[disabled]).
Voir la solution
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Binding {
recherche = '';
effacer(): void {
this.recherche = '';
}
}<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="recherche" placeholder="Rechercher une recette">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button [disabled]="recherche.trim() === ''" (click)="effacer()">Effacer</button>
<!-- {{ }} : affiche la valeur de la classe -->
<p>Vous cherchez : {{ recherche }}</p>Le trim() évite de considérer comme « rempli » un champ qui ne contient que des espaces.
Étape 4 Le bouton Emprunter et la recherche
Ce qui est réalisé : Dans cette étape, nous rendons la carte interactive :
- le lecteur peut emprunter le média ;
- la page reçoit un champ de recherche (il ne filtre pas encore : nous le brancherons plus tard).
Fichiers concernés : src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/app.css (à la fin du fichier) · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 7
Quelle syntaxe affiche la valeur d'une propriété dans le texte d'un template ?
L'interpolation utilise les doubles accolades.
Que fait
[disabled]="stock === 0"?Les crochets déclenchent un property binding : l'expression est calculée.
Quelle est la bonne syntaxe pour réagir à un clic ?
Les parenthèses écoutent l'événement, et il faut appeler la méthode avec ().
Que faut-il importer pour utiliser
[(ngModel)]?ngModel fait partie du module de formulaires FormsModule.
Pourquoi une méthode
privaten'est-elle pas accessible depuis le template ?Les membres private restent internes à la classe.
Code de Médiathèque à la fin du chapitre (5 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
title: string = 'Dune';
author: string = 'Frank Herbert';
type: 'livre' | 'film' | 'jeu' = 'livre';
available: boolean = true;
borrowed: boolean = false; // nouveau : le lecteur a-t-il emprunté ce média ?
borrow(): void { // nouveau : appelée au clic sur le bouton
this.borrowed = true;
}
statusLabel(): string {
if (this.borrowed) return 'Emprunté';
return this.available ? 'Disponible' : 'Indisponible';
}
}<article class="card">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ title }}</h2>
<p class="author">{{ author }}</p>
<div class="meta">
<span class="badge">{{ type }}</span>
<span class="badge"
[class.badge-ok]="available && !borrowed"
[class.badge-warn]="borrowed"
[class.badge-bad]="!available">
{{ statusLabel() }}
</span>
</div>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" [disabled]="!available || borrowed" (click)="borrow()">Emprunter</button>
</article>/* Barre de recherche */
.search { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search input { flex: 1; min-width: 0; }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MediaCard } from './media-card/media-card';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [FormsModule, MediaCard],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
search = '';
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<div class="search">
<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="search" placeholder="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="search.trim() === ''" (click)="search = ''">Effacer</button>
</div>
<section class="grid">
<app-media-card />
<app-media-card />
<app-media-card />
</section>
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 8 · TypeScript et réactivité
Les signals : l'état réactif
Comprendre les signals, savoir créer un état, le modifier, dériver une valeur calculée, et réagir à ses changements.
Glossaire du chapitre
- Signal
- Valeur réactive : Angular sait qui la lit et met à jour seulement ce qui en dépend.
- set / update
- Méthodes qui modifient un signal :
set(valeur)ouupdate(v => v + 1). - computed
- Valeur dérivée d’autres signaux. Elle se recalcule seule, et seulement quand c’est nécessaire.
- effect
- Code exécuté automatiquement chaque fois qu’un signal lu à l’intérieur change.
- Lecture avec ()
- Un signal se lit comme une fonction :
compteur(), avec les parenthèses.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous passons à un exemple tout autre que Médiathèque : un calculateur de portions pour une recette. Vous créerez un composant Portions pour essayer les extraits (puis <app-portions /> dans app.html).
Au programme : 8.1 Pourquoi les signals ? · 8.2 signal() : lire et écrire · 8.3 computed() : une valeur dérivée · 8.4 effect() : réagir à un changement · 8.5 Quand utiliser un signal ?
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
4 exercices
Exercice 8.2.A Un compteur de placesEssentiel
Dans demo, créez un composant places (ng generate component places), affiché dans App. Il gère les places libres d'une séance de cinéma :
- un signal
libres, de type nombre, qui vaut10au départ ; - un bouton Réserver qui retire une place, sans jamais descendre sous
0(utilisezupdate) ; - un bouton Annuler une réservation qui rend une place, sans dépasser
10; - un bouton Remettre à 10 qui utilise
set; - le template affiche
{{ libres() }}et désactive Réserver quand il ne reste plus de place ([disabled]).
Pensez aux parenthèses : un signal se lit avec libres(), dans le template comme dans la classe.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, signal } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-places',
templateUrl: './places.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Places {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
libres = signal(10);
reserver(): void {
this.libres.update((n) => Math.max(0, n - 1));
}
annuler(): void {
this.libres.update((n) => Math.min(10, n + 1));
}
remettre(): void {
this.libres.set(10);
}
}<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ libres() }} place(s) libre(s)</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button [disabled]="libres() === 0" (click)="reserver()">Réserver</button>
<button (click)="annuler()">Annuler une réservation</button>
<button (click)="remettre()">Remettre à 10</button>update reçoit la valeur actuelle et renvoie la nouvelle : c'est le bon outil dès que la nouvelle valeur dépend de l'ancienne. set remplace directement la valeur. Math.max et Math.min servent de garde-fous (jamais sous 0, jamais au-dessus de 10).
Exercice 8.3.A Le total d'un panierEssentiel
Dans demo, créez un composant panier, affiché dans App. Il calcule le total d'une commande :
- un signal
prix(nombre,12.5au départ) et un signalquantite(nombre,1au départ) ; - deux boutons + et − qui modifient
quantite(jamais sous1) ; - un
computedtotalqui vautprix() * quantite(); - un second
computedlivraisonOffertequi vauttruequandtotal()atteint30; - le template affiche la quantité, le total et « Livraison offerte » ou « Livraison payante ».
Ne stockez pas le total dans un signal : il se déduit des deux autres, c'est le rôle de computed.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-panier',
templateUrl: './panier.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Panier {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
prix = signal(12.5);
quantite = signal(1);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
total = computed(() => this.prix() * this.quantite());
livraisonOfferte = computed(() => this.total() >= 30);
plus(): void {
this.quantite.update((q) => q + 1);
}
moins(): void {
this.quantite.update((q) => Math.max(1, q - 1));
}
}<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="moins()">−</button>
<!-- {{ }} : affiche la valeur de la classe -->
<span>{{ quantite() }}</span>
<button (click)="plus()">+</button>
<p>Total : {{ total() }} €</p>
<p>{{ livraisonOfferte() ? 'Livraison offerte' : 'Livraison payante' }}</p>total et livraisonOfferte ne sont jamais modifiés à la main : Angular les recalcule seul à chaque changement de quantite (ou de prix). livraisonOfferte dépend même d'un autre computed, ce qui est tout à fait possible.
Exercice 8.5.A Ma liste de coursesEssentiel
Dans demo, construisez un composant courses :
- un champ de saisie lié par
ngModelà une propriéténom(l'article à acheter) ; - un bouton Ajouter qui ajoute l'article à un signal
articles(tableau d'objets{ id: number; nom: string; achete: boolean }) ; - chaque article s'affiche dans une liste, avec une case à cocher qui change son état
achete; - un
computedrestantqui affiche le nombre d'articles pas encore achetés. (La boucle@fordu modèle sera expliquée au chapitre suivant : recopiez-la telle quelle.)
Pour déclarer articles, relisez la décomposition de signal<Article[]>([]) en 8.2 : il faut d'abord une interface Article, puis un signal typé qui part d'un tableau vide.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface Article {
id: number;
nom: string;
achete: boolean;
}
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-courses',
imports: [FormsModule],
templateUrl: './courses.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Courses {
nom = '';
articles = signal<Article[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
restant = computed(() => this.articles().filter((a) => !a.achete).length);
ajouter(): void {
if (this.nom.trim() === '') return;
this.articles.update((liste) => [...liste, { id: Date.now(), nom: this.nom.trim(), achete: false }]);
this.nom = '';
}
basculer(index: number): void {
this.articles.update((liste) =>
liste.map((a, i) => (i === index ? { ...a, achete: !a.achete } : a)),
);
}
}<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="nom" placeholder="Article à acheter">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="ajouter()">Ajouter</button>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (article of articles(); track article.id; let i = $index) {
<li>
<input type="checkbox" [checked]="article.achete" (change)="basculer(i)">
<!-- {{ }} : affiche la valeur de la classe -->
<span>{{ article.nom }}</span>
</li>
}
</ul>
<p>{{ restant() }} article(s) à acheter</p>Ici, update reçoit l'ancienne liste et renvoie une nouvelle liste (grâce au spread ... et à map). Angular détecte le changement de référence : c'est la bonne façon de modifier un tableau dans un signal.
Exercice 8.5.B Retirer le dernier élémentBonus
Le lecteur s'est trompé : il doit pouvoir enlever le dernier élément d'une liste sans tout vider. Créez dans demo un composant courses (ng generate component courses), affiché dans Articles :
- une liste de courses
courses = signal<string[]>(['Œufs', 'Farine', 'Lait']), affichée avec@for; - une méthode
removeLast()qui retire le dernier élément (utilisezupdateetslice) ; - un bouton Retirer le dernier, désactivé quand la liste est vide ;
- un
computedcountaffiché sous la liste : « 2 articles ».
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-courses',
templateUrl: './courses.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Courses {
courses = signal<string[]>(['Œufs', 'Farine', 'Lait']);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
count = computed(() => this.courses().length);
removeLast(): void {
this.courses.update((list) => list.slice(0, -1));
}
}<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (c of courses(); track c) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ c }}</li>
}
</ul>
<p>{{ count() }} articles</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button [disabled]="courses().length === 0" (click)="removeLast()">Retirer le dernier</button>slice(0, -1) renvoie une nouvelle liste sans le dernier élément : le signal détecte le changement, et count se recalcule seul.
Étape 5 La sélection et ses places restantes
Ce qui est réalisé : Dans cette étape, nous ajoutons à Médiathèque un panneau de sélection qui montre :
- combien de médias le lecteur a choisis ;
- combien de places il lui reste.
Fichiers concernés : src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html · src/app/selection-panel/selection-panel.css · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 8
Comment lit-on la valeur d'un signal nommé
count?Un signal est une fonction qui renvoie sa valeur : on l'appelle avec des parenthèses.
Comment ajoute-t-on 1 à un signal
countà partir de son ancienne valeur ?update reçoit la valeur actuelle et renvoie la nouvelle.
Quelle différence entre
signal()etcomputed()?Un computed se recalcule automatiquement à partir de ses sources.
Que fait un
effect()?L'effect sert aux effets de bord, par exemple journaliser un changement.
Pourquoi utilise-t-on
items.update(list => [...list, media])plutôt que de modifier le tableau sur place ?Un signal ne prévient Angular que lorsque sa valeur est remplacée par une autre : Angular compare l'ancienne et la nouvelle référence. Si on appelle
pushsur le tableau existant, la référence reste la même : le signal ne se rend pas compte du changement et l'écran n'est pas mis à jour.[...list, media]crée un nouveau tableau (copie de l'ancien plus le nouvel élément) : la référence change, le signal notifie ses lecteurs et l'affichage suit. Ce n'est pas une obligation du navigateur, etpushn'est pas interdit par le langage : il ne déclenche simplement pas la mise à jour. L'ancien tableau reste en plus inchangé, ce qui évite des bugs quand plusieurs parties du code le lisent.
Code de Médiathèque à la fin du chapitre (5 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
import { MEDIAS } from '../data/medias';
import { Media, MAX_LOANS } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
readonly max = MAX_LOANS;
readonly items = signal<Media[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly remaining = computed(() => this.max - this.items().length);
readonly isFull = computed(() => this.remaining() === 0);
add(): void {
if (this.isFull()) return;
this.items.update((list) => [...list, MEDIAS[list.length]]);
}
clear(): void {
this.items.set([]);
}
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ items().length }} / {{ max }} médias</p>
<p class="muted">Places restantes : {{ remaining() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" [disabled]="isFull()" (click)="add()">Ajouter un média</button>
<button class="btn btn-secondary" (click)="clear()">Vider</button>
</aside>/* Panneau « Ma sélection » */
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1rem 1.25rem; box-shadow: var(--shadow);
margin-top: 1.5rem;
}
.panel h2 { font-size: 1.1rem; }
.panel .btn { margin-right: 0.5rem; margin-top: 0.5rem; }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MediaCard } from './media-card/media-card';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [FormsModule, MediaCard, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
search = '';
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<div class="search">
<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="search" placeholder="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="search.trim() === ''" (click)="search = ''">Effacer</button>
</div>
<section class="grid">
<app-media-card />
<app-media-card />
<app-media-card />
</section>
<app-selection-panel />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 9 · TypeScript et réactivité
Le contrôle de flux : @if, @for, @switch
Afficher des éléments conditionnellement et répéter un affichage pour chaque élément d'une liste, avec la syntaxe moderne des templates.
Glossaire du chapitre
- @if / @else
- Affiche un bloc seulement si une condition est vraie, sinon affiche l’autre bloc.
- @for
- Répète un bloc pour chaque élément d’une liste.
- track
- Identifiant de chaque ligne d’une liste. Il aide Angular à mettre à jour seulement ce qui a changé.
- @empty
- Bloc affiché quand la liste répétée est vide.
- @switch
- Choisit un bloc selon la valeur d’une expression, comme un
switchen PHP.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous essayons les blocs de contrôle de flux avec un exemple sans rapport avec Médiathèque : un carnet de recettes et un feu tricolore. Créez un composant Recettes et affichez-le dans app.html de demo.
Au programme : 9.1 Des directives aux blocs · 9.2 @if, @else if et @else · 9.3 @for et l'identifiant de suivi (track) · 9.4 Le bloc @empty · 9.5 @switch : plusieurs cas
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 9.5.A Un feu tricoloreEssentiel
Dans demo, créez un composant feu avec une propriété couleur qui vaut 'rouge', 'orange' ou 'vert'. Affichez le message correspondant avec @switch (« Stop », « Ralentir », « Passez »), puis un bouton qui passe à la couleur suivante (rouge → vert → orange → rouge).
Deux outils à connaître avant de commencer.
indexOf: la méthode d'un tableau qui renvoie la position (à partir de 0) d'un élément. Avecconst ordre = ['rouge', 'vert', 'orange'],ordre.indexOf('vert')vaut1, etordre.indexOf('orange')vaut2. Elle renvoie-1si l'élément est absent. Ici, elle sert à retrouver où l'on en est dans la liste des couleurs.- Le modulo
%: il donne le reste d'une division entière.7 % 3vaut1(7 = 2 × 3 + 1) et3 % 3vaut0. Ajouter 1 à la position puis prendre le reste de la division par la longueur du tableau permet de revenir au début après le dernier élément.
Avec ordre = ['rouge', 'vert', 'orange'], de longueur 3, la formule ordre[(i + 1) % ordre.length] donne :
| Couleur actuelle | i | i + 1 | (i + 1) % 3 | Résultat |
|---|---|---|---|---|
| rouge | 0 | 1 | 1 | vert |
| vert | 1 | 2 | 2 | orange |
| orange | 2 | 3 | 0 | rouge |
Quand on est sur orange (position 2), i + 1 vaut 3, une position qui n'existe pas dans le tableau. Le modulo calcule le reste de la division par 3 : 3 % 3 = 0, donc on retombe sur rouge, le premier élément. C'est ce qui fait boucler le feu.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
type Couleur = 'rouge' | 'orange' | 'vert';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-feu',
templateUrl: './feu.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Feu {
couleur: Couleur = 'rouge';
suivant(): void {
const ordre: Couleur[] = ['rouge', 'vert', 'orange'];
const i = ordre.indexOf(this.couleur);
this.couleur = ordre[(i + 1) % ordre.length];
}
}<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (couleur) {
@case ('rouge') { <p>Stop</p> }
@case ('orange') { <p>Ralentir</p> }
@case ('vert') { <p>Passez</p> }
}
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="suivant()">Couleur suivante</button>Le type Couleur interdit toute autre valeur : le compilateur signale une faute de frappe. L'opérateur % fait revenir au début de la liste après la dernière couleur.
Étape 6 Le catalogue complet
Ce qui est réalisé : Dans cette étape, nous :
- affichons les 12 médias de
MEDIASavec une boucle ; - adaptons l'icône à leur type ;
- affichons leur disponibilité ;
- prévoyons le cas « liste vide ».
Fichiers concernés : src/app/app.css (à la fin du fichier) · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 9
Quelle syntaxe est recommandée pour afficher une liste dans Angular récent ?
Le bloc
@forest intégré au langage et impose d'écrire track.À quoi sert la clause
trackdans un@for?track donne une clé stable à chaque élément, comme un identifiant unique.
Que s'affiche si la liste est vide, dans un bloc
@foravec@empty?Le bloc
@emptyremplace le rendu quand la collection n'a aucun élément.Quel est le rôle de
$indexdans un@for?$index donne la position courante de l'élément.
Quel bloc permet de choisir un affichage parmi plusieurs valeurs ?
@switchcompare une valeur à plusieurs@case, avec un@defaultde secours.
Code de Médiathèque à la fin du chapitre (3 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
/* Cartes du catalogue affichées directement dans le composant racine (chapitres 9 et 10).
À copier à la fin de src/app/app.css. */
.card {
display: flex; flex-direction: column; gap: 0.5rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1rem; box-shadow: var(--shadow);
transition: transform 0.15s, box-shadow 0.15s;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(16, 24, 40, 0.12); }
.card h2 { font-size: 1.1rem; margin: 0; }
.card .author { margin: 0; color: var(--muted); }
.card .meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: auto; }
.card--out { opacity: 0.6; }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MEDIAS } from './data/medias';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [FormsModule, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
search = '';
medias = MEDIAS;
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<div class="search">
<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="search" placeholder="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="search.trim() === ''" (click)="search = ''">Effacer</button>
</div>
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of medias; track m.id) {
<article class="card">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ m.title }}</h2>
<p class="author">{{ m.author }} · {{ m.year }}</p>
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (m.type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ m.type }}
</span>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (m.available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (m.year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
</article>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<p class="muted">Aucun média dans le catalogue.</p>
}
</section>
<app-selection-panel />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 3 · 3 / 19
Composants en pratique
Chapitre 10 · Composants en pratique
Les directives d'attribut
Modifier l'apparence ou le comportement d'un élément existant, avec les classes CSS dynamiques, et créer sa propre directive.
Glossaire du chapitre
- Directive
- Instruction qui modifie un élément HTML : ajouter une classe, changer le style, réagir à la souris.
- [class.nom]
- Ajoute ou retire une classe CSS selon une condition.
- [style.nom]
- Fixe une propriété CSS selon une valeur de la classe.
- host
- Section d’une directive qui décrit ses écouteurs d’événements et ses attributs.
- ElementRef
- Référence vers l’élément HTML sur lequel la directive est posée.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous gardons le carnet de recettes :
- les extraits de cette partie s'essaient dans un composant
Recette: la fiche d'une recette, avec son titre, sa note et son caractère végétarien ; - pour l'instant, ses données sont de simples propriétés ;
- au chapitre suivant, elles deviendront des entrées reçues d'un parent.
Au programme : 10.1 Qu'est-ce qu'une directive d'attribut ? · 10.2 Les liaisons [class] et [style] · 10.3 ngClass et ngStyle · 10.4 Créer sa propre directive
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
4 exercices
Exercice 10.4.A Un indicateur de cuissonEssentiel
Dans le composant Recette :
- Ajoutez une propriété
cuisson(nombre,0au départ) et une méthodeavancer()qui ajoute10sans dépasser100. - Affichez une
<div class="barre">dont la largeur vautcuissonen pourcentage ([style.width.%]). - Ajoutez la classe
preteà cette barre quandcuissonvaut100([class.prete]). - Ajoutez un bouton Avancer qui appelle
avancer(), désactivé quand la cuisson est terminée.
Le fichier recette.css contient déjà la règle .barre (section 10.2). Ajoutez-y la règle .prete { background: green; }, qui change la couleur de la barre quand la cuisson est terminée.
Voir la solution
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Recette {
cuisson = 0;
avancer(): void {
this.cuisson = Math.min(100, this.cuisson + 10);
}
}<!-- [class.nom] : ajoute ou retire la classe selon la condition -->
<div class="barre" [style.width.%]="cuisson" [class.prete]="cuisson === 100"></div>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button [disabled]="cuisson === 100" (click)="avancer()">Avancer</button>Math.min(100, ...) empêche de dépasser 100 : sans lui, la largeur pourrait atteindre 110 % et la condition cuisson === 100 ne serait plus jamais vraie.
Exercice 10.4.B Une directive de surbrillanceEssentiel
Dans demo, dans le composant Recette, créez une directive appSurligne qui surligne un élément au survol, sans toucher à son template :
- créez le fichier
src/app/recette/surligne.tsavec une classeSurligne(sélecteur[appSurligne]) ; - dans la classe, un signal
actif(booléen,falseau départ) ; - avec l'option
host,mouseentermetactifàtrue,mouseleavele remet àfalse, et la classe CSSsurligneest présente sur l'élément quandactif()vauttrue('[class.surligne]') ; - ajoutez la règle
.surligne { background: #fff3b0; }dansrecette.css; - importez la directive dans
Recetteet appliquez-la à deux éléments derecette.html.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Directive, signal } from '@angular/core';
// Déclare une directive, qui ajoute un comportement à un élément
@Directive({
selector: '[appSurligne]',
host: {
'(mouseenter)': 'actif.set(true)',
'(mouseleave)': 'actif.set(false)',
'[class.surligne]': 'actif()',
},
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Surligne {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
actif = signal(false);
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { Surligne } from './surligne';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-recette',
imports: [Surligne],
templateUrl: './recette.html',
styleUrl: './recette.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Recette {
// ... le reste du composant ne change pas
}<!-- {{ }} : affiche la valeur de la classe -->
<h2 appSurligne>{{ titre }}</h2>
<p appSurligne>Note : {{ note }}</p>.surligne {
background: #fff3b0;
}La directive ne connaît ni le titre ni la note : elle pose un comportement sur n'importe quel élément portant l'attribut. Deux éléments avec appSurligne ont chacun leur propre instance de Surligne, donc leur propre signal actif.
Exercice 10.4.C Des majuscules automatiquesEssentiel
Dans demo, dans le composant Recette, créez une directive appMajuscules à poser sur un champ <input> : tout ce que l'on tape s'affiche en majuscules.
Deux notions nouvelles pour cet exercice.
$event: dans unhost,'(input)': 'forcer($event)'transmet à la méthode l'objet événement, qui décrit ce qui vient de se passer. Son type estEvent.event.target: l'élément qui a déclenché l'événement, ici le champ de saisie. TypeScript le type de façon générale (EventTarget), donc il ne connaît pas sa propriétévalue. On lui précise le type avecas HTMLInputElement:const champ = event.target as HTMLInputElement;. Ensuite,champ.valueest le texte saisi, et on peut aussi lui affecter une nouvelle valeur.
Pour mettre un texte en majuscules, on utilise texte.toUpperCase().
- créez
src/app/recette/majuscules.tsavec une classeMajuscules(sélecteur[appMajuscules]) ; - avec
host, l'événement(input)appelle une méthodeforcer($event); - dans
forcer(event: Event), récupérez le champ avecevent.target as HTMLInputElementet remplacez sa valeur parchamp.value.toUpperCase(); - importez la directive dans
Recetteet posez-la sur<input appMajuscules placeholder="Nom de la recette">.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Directive } from '@angular/core';
// Déclare une directive, qui ajoute un comportement à un élément
@Directive({
selector: '[appMajuscules]',
host: {
'(input)': 'forcer($event)',
},
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Majuscules {
forcer(event: Event): void {
const champ = event.target as HTMLInputElement;
champ.value = champ.value.toUpperCase();
}
}<input appMajuscules placeholder="Nom de la recette">event.target est typé de façon générale (EventTarget) : as HTMLInputElement précise à TypeScript qu'il s'agit bien d'un champ de saisie, qui possède une propriété value. Pensez à ajouter Majuscules dans le tableau imports de Recette.
Exercice 10.4.D Un champ qui n'accepte que des chiffresBonus
Dans demo, dans le composant Recette, créez une directive appChiffres à poser sur un champ <input> : tout caractère qui n'est pas un chiffre disparaît dès qu'on le tape. Vous avez déjà tout ce qu'il faut avec l'exercice précédent : c'est la même démarche, avec une autre transformation.
- créez
src/app/recette/chiffres.tsavec une classeChiffres(sélecteur[appChiffres]) ; - avec
host, l'événement(input)appelle une méthodefiltrer($event); - dans
filtrer, récupérez le champ comme à l'exercice C, puis remplacez sa valeur parchamp.value.replace(/\D/g, '')(\Ddésigne tout caractère qui n'est pas un chiffre) ; - importez la directive dans
Recetteet posez-la sur<input appChiffres placeholder="Nombre de personnes">.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Directive } from '@angular/core';
// Déclare une directive, qui ajoute un comportement à un élément
@Directive({
selector: '[appChiffres]',
host: {
'(input)': 'filtrer($event)',
},
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Chiffres {
filtrer(event: Event): void {
const champ = event.target as HTMLInputElement;
champ.value = champ.value.replace(/\D/g, '');
}
}<input appChiffres placeholder="Nombre de personnes">replace(/\D/g, '') remplace tous les caractères (g pour global) qui ne sont pas des chiffres par du texte vide, donc les supprime. Pensez à ajouter Chiffres dans le tableau imports de Recette.
Étape 7 Des cartes qui réagissent
Ce qui est réalisé : Dans cette étape :
- nous grisons les médias indisponibles ;
- nous soulignons les médias les mieux notés ;
- nous faisons réagir chaque carte au survol de la souris, avec une directive que nous écrivons nous-mêmes.
Fichiers concernés : src/app/highlight.ts · src/app/app.ts · src/app/app.html · src/app/app.css
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 10
Qu'est-ce qu'une directive d'attribut ?
Elle s'accroche à un élément sans créer de nouveau template.
Quelle écriture ajoute la classe
activeseulement siisActiveest vrai ?Le binding sur class.nom active ou désactive la classe.
Pourquoi importe-t-on NgClass dans le composant ?
Les directives et composants doivent être listés dans imports.
Quel décorateur sert à déclarer une directive ?
@Directivedéclare une directive, avec son sélecteur et ses hôtes.Pourquoi le sélecteur de la directive de l'exemple est-il
[appHighlight]?Le sélecteur entre crochets cible un attribut, pas une balise.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Directive, ElementRef, inject } from '@angular/core';
// Déclare une directive, qui ajoute un comportement à un élément
@Directive({
selector: '[appHighlight]',
host: {
'(mouseenter)': 'hover(true)',
'(mouseleave)': 'hover(false)',
},
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Highlight {
private element = inject<ElementRef<HTMLElement>>(ElementRef);
hover(active: boolean): void {
const style = this.element.nativeElement.style;
style.backgroundColor = active ? 'var(--primary-soft)' : '';
style.outline = active ? '2px solid var(--primary)' : '';
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Highlight } from './highlight';
import { MEDIAS } from './data/medias';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [FormsModule, SelectionPanel, Highlight],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
search = '';
medias = MEDIAS;
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<div class="search">
<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="search" placeholder="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="search.trim() === ''" (click)="search = ''">Effacer</button>
</div>
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of medias; track m.id) {
<article class="card" appHighlight
[class.card--out]="!m.available"
[class.card--top]="(m.rating ?? 0) >= 4.5">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ m.title }}</h2>
<p class="author">{{ m.author }} · {{ m.year }}</p>
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (m.type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ m.type }}
</span>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (m.available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (m.year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
</article>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<p class="muted">Aucun média dans le catalogue.</p>
}
</section>
<app-selection-panel />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>.card--top { border-color: var(--primary); }Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 11 · Composants en pratique
Communiquer entre composants : input()
Transmettre des données d'un composant parent à un composant enfant avec input(), et les valider ou les transformer.
Glossaire du chapitre
- input()
- Donnée reçue du composant parent. On la lit avec des parenthèses :
média(). - input.required()
- Entrée obligatoire : le parent doit la fournir, sinon Angular signale une erreur.
- booleanAttribute
- Transformation qui permet d’écrire simplement
<app-bouton desactive>. - Transformation
- Conversion d’une valeur reçue, par exemple du texte transformé en nombre.
- @Input
- Ancienne façon de recevoir une donnée. Elle fonctionne encore : on la rencontre dans les projets existants.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous créons un composant enfant recette-carte et nous l'utilisons dans le template de App (n'oubliez pas de l'ajouter à imports). Cette partie reste dans le carnet de recettes.
Au programme : 11.1 Le sens de la communication · 11.2 input() : recevoir une donnée · 11.3 input.required() : une valeur obligatoire · 11.4 Transformer la valeur d'une entrée
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 11.4.A Des étoiles pour une noteBonus
Dans demo, créez un composant etoiles :
- une entrée obligatoire
note(nombre) ; - une entrée
max(nombre,5par défaut, transformée parnumberAttribute) ; - une méthode
affichage()qui renvoie une chaîne demaxcaractères :★pour chaque point de la note,☆pour le reste (la note est un nombre entier compris entre0etmax) ; - utilisez-le dans
App:<app-etoiles [note]="4" />et<app-etoiles [note]="2" max="3" />.
Un outil à connaître avant de commencer. texte.repeat(n) est la méthode d'une chaîne qui la répète n fois. '★'.repeat(3) donne '★★★', et '☆'.repeat(0) donne une chaîne vide. C'est exactement ce qu'il faut ici : au lieu d'écrire une boucle, on répète le caractère plein autant de fois que la note, puis le caractère vide pour le reste.
Avec note = 4 et max = 5 : '★'.repeat(4) donne ★★★★, et 5 - 4 = 1 étoile vide, donc '☆'.repeat(1). L'affichage est ★★★★☆.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input, numberAttribute } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-etoiles',
templateUrl: './etoiles.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Etoiles {
// input.required : valeur obligatoire, fournie par le parent
note = input.required<number>();
// input() : valeur fournie par le parent ; on la lit avec ()
max = input(5, { transform: numberAttribute });
affichage(): string {
const pleines = this.note();
return '★'.repeat(pleines) + '☆'.repeat(this.max() - this.note());
}
}<!-- {{ }} : affiche la valeur de la classe -->
<span>{{ affichage() }}</span>Dans App, max="3" est un texte : sans numberAttribute, this.max() - this.note() produirait une soustraction sur du texte. repeat répète une chaîne : pleines fois l'étoile pleine, puis max - note fois l'étoile vide. Attention : si la note dépassait max, le nombre de répétitions serait négatif et repeat provoquerait une erreur ; c'est pourquoi l'énoncé impose une note entre 0 et max.
Étape 8 La carte pilotée par son média
Ce qui est réalisé : Dans cette étape, nous rendons à MediaCard sa raison d'être : un composant qui
Fichiers concernés : src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/media-card/media-card.css · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 11
Dans quel sens circulent les données entre composants ?
Les données descendent, les événements remontent.
Comment lit-on la valeur d'une entrée
title = input('')dans le template ?Une entrée est un signal : on l'appelle avec des parenthèses.
Quelle est la différence entre
input(0)etinput.required<number>()?Une entrée required est obligatoire.
Pourquoi écrit-on
title="Dune"sans crochets, mais[media]="m"avec crochets ?Les crochets évaluent une expression TypeScript.
Peut-on modifier directement une entrée à l'intérieur de l'enfant ?
L'enfant doit passer par une sortie ou un model pour changer une valeur.
Code de Médiathèque à la fin du chapitre (5 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, booleanAttribute, input } from '@angular/core';
import { Highlight } from '../highlight';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
imports: [Highlight],
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
// input() : valeur fournie par le parent ; on la lit avec ()
compact = input(false, { transform: booleanAttribute });
}<article class="card" appHighlight
[class.card--out]="!media().available"
[class.card--top]="(media().rating ?? 0) >= 4.5">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ media().title }}</h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (!compact()) {
<p class="author">{{ media().author }} · {{ media().year }}</p>
}
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (media().type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ media().type }}
</span>
@if (media().available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (media().year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
<button class="btn" [disabled]="!media().available">Emprunter</button>
</article>/* Carte d'un média */
.card { display: flex; flex-direction: column; gap: 0.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
.card h2 { font-size: 1.1rem; margin: 0; }
.card .author { margin: 0; color: var(--muted); }
.card .meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: auto; }
.card--out { opacity: 0.6; }
.card--top { border-color: var(--primary); }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MEDIAS } from './data/medias';
import { MediaCard } from './media-card/media-card';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [FormsModule, MediaCard, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
search = '';
medias = MEDIAS;
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<div class="search">
<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="search" placeholder="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="search.trim() === ''" (click)="search = ''">Effacer</button>
</div>
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of medias; track m.id) {
<app-media-card [media]="m" />
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<p class="muted">Aucun média dans le catalogue.</p>
}
</section>
<app-selection-panel />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 12 · Composants en pratique
Communiquer entre composants : output() et model()
Remonter un événement de l'enfant vers le parent avec output(), et synchroniser une valeur dans les deux sens avec model().
Glossaire du chapitre
- output()
- Événement qu’un composant émet vers son parent, avec la méthode
emit(). - model()
- Entrée et sortie réunies : la valeur est lue et modifiée, dans les deux sens.
- Sortie
- Ce qu’un composant renvoie vers le parent quand quelque chose se passe.
- EventEmitter
- Ancienne façon de créer une sortie. Elle se remplace par
output().
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous reprenons le composant enfant recette-carte du chapitre 11 (avec son entrée recette) et nous le faisons dialoguer avec App. Les extraits s'essaient dans App et dans ses enfants.
Au programme : 12.1 output() : émettre un événement · 12.2 Écouter une sortie dans le parent · 12.3 model() : une valeur synchronisée dans les deux sens · 12.4 output() ou model() : les vraies différences
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
2 exercices
Exercice 12.2.A Un bouton « J'aime »Essentiel
Dans demo, créez un composant bouton-aime qui signale au parent qu'on aime une recette :
- une entrée obligatoire
recette(texte : le nom de la recette) ; - une sortie
aime(output<string>()) qui émet le nom de la recette ; - un bouton ♥ J'aime dont le clic appelle une méthode qui fait
emit(...)avec le nom de la recette ; - dans
App, un signalaimees(signal<string[]>([])) et une méthodeonAime(nom)qui ajoute le nom à la liste s'il n'y est pas déjà (includes) ; - dans
app.html, affichez deux boutons (CrêpesetRatatouille) qui écoutent(aime)="onAime($event)", puis une ligne2 recette(s) aimée(s) : Crêpes, Ratatouille.
Rappel : le composant enfant ne garde aucune liste. Il annonce ce qui s'est passé ; le parent décide quoi en faire.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input, output } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-bouton-aime',
templateUrl: './bouton-aime.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class BoutonAime {
// input.required : valeur obligatoire, fournie par le parent
recette = input.required<string>();
aime = output<string>(); // émet le nom de la recette aimée
onClick(): void {
this.aime.emit(this.recette());
}
}<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="onClick()">♥ J'aime {{ recette() }}</button>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, signal } from '@angular/core';
import { BoutonAime } from './bouton-aime/bouton-aime';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [BoutonAime], // + les composants déjà présents
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
aimees = signal<string[]>([]);
onAime(nom: string): void {
if (this.aimees().includes(nom)) return; // déjà aimée : on ne fait rien
this.aimees.update((liste) => [...liste, nom]); // nouvelle liste, comme au chapitre 8
}
}<app-bouton-aime recette="Crêpes" (aime)="onAime($event)" />
<app-bouton-aime recette="Ratatouille" (aime)="onAime($event)" />
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ aimees().length }} recette(s) aimée(s) : {{ aimees().join(', ') }}</p>Le code
- Dans l'enfant,
aime = output<string>()déclare la sortie, etemit(this.recette())envoie le nom de la recette. L'enfant n'a rien à stocker : il annonce seulement « on m'a aimé ». - Dans le parent,
(aime)="onAime($event)"écoute cette sortie.$eventcontient le texte émis, ici le nom de la recette. includes(nom)Teste si le nom est déjà dans la liste. Cela évite les doublons : un second clic sur « Crêpes » ne change rien. C'est le parent qui applique cette règle, pas le bouton : le bouton reste simple et réutilisable.updateFabrique une nouvelle liste ([...liste, nom]), etaimees().join(', ')colle les noms avec une virgule pour l'affichage.
Exercice 12.3.A Noter une recetteEssentiel
Dans demo, créez un composant note-saisie :
- un
modelnote(nombre,0au départ) ; - cinq boutons
1à5générés avec@for(@for (n of [1, 2, 3, 4, 5]; track n)), qui donnent ànotela valeur du bouton cliqué : utiliseznote.set(n)dans le(click); - une classe
choisisur le bouton dont le numéro est égal à la note ([class.choisi]) ; - dans
App, utilisez<app-note-saisie [(note)]="maNote" />avec un signalmaNoteet affichezMa note : ….
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, model } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-note-saisie',
templateUrl: './note-saisie.html',
styleUrl: './note-saisie.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class NoteSaisie {
// model() : valeur lue et modifiable, synchronisée avec le parent
note = model(0);
}<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (n of [1, 2, 3, 4, 5]; track n) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button [class.choisi]="n === note()" (click)="note.set(n)">{{ n }}</button>
}Le style. Dans note-saisie.css, mettons en valeur la note choisie :
button {
margin-right: 0.25rem;
}
.choisi {
background: gold;
font-weight: bold;
}<app-note-saisie [(note)]="maNote" />
<!-- {{ }} : affiche la valeur de la classe -->
<p>Ma note : {{ maNote() }}</p>Un model s'écrit avec set() comme un signal : l'écriture dans l'enfant remonte automatiquement au signal maNote du parent.
Étape 9 Sélection et recherche
Ce qui est réalisé : Dans cette étape, nous relions les composants entre eux, en deux temps :
- Partie A : brancher l'emprunt. Le clic sur Emprunter remonte de la carte vers la page, qui construit la vraie sélection ; le panneau l'affiche.
- Partie B : brancher la recherche. Nous remplaçons la barre de recherche provisoire par un composant
search-barqui partage son texte avec la page, et le catalogue se filtre au fil de la frappe.
Fichiers concernés : src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/app.ts · src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html · src/app/app.html · src/app/search-bar/search-bar.css · src/app/search-bar/search-bar.ts · src/app/search-bar/search-bar.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 12
Comment un enfant émet-il un événement vers son parent ?
Une sortie se déclare avec
output(), etemit()envoie la valeur au parent.Comment le parent écoute-t-il une sortie nommée
borrow?Les parenthèses écoutent l'événement, et $event contient la donnée émise.
Que permet
model()par rapport àinput()?model()combine une entrée et une sortie pour une valeur partagée.Quelle syntaxe utilise le parent pour un model nommé
query?La syntaxe à double crochets-parenthèses synchronise les deux sens.
Quand préférer
output()àmodel()?Un événement (clic, validation) se gère avec
output().
Code de Médiathèque à la fin du chapitre (9 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, booleanAttribute, input, output } from '@angular/core';
import { Highlight } from '../highlight';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
imports: [Highlight],
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
// input() : valeur fournie par le parent ; on la lit avec ()
compact = input(false, { transform: booleanAttribute });
borrow = output<Media>(); // nouveau : émise au clic sur Emprunter
}<article class="card" appHighlight
[class.card--out]="!media().available"
[class.card--top]="(media().rating ?? 0) >= 4.5">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ media().title }}</h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (!compact()) {
<p class="author">{{ media().author }} · {{ media().year }}</p>
}
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (media().type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ media().type }}
</span>
@if (media().available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (media().year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" [disabled]="!media().available" (click)="borrow.emit(media())">Emprunter</button>
</article>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
import { MEDIAS } from './data/medias';
import { Media, MAX_LOANS } from './models/media';
import { MediaCard } from './media-card/media-card';
import { SearchBar } from './search-bar/search-bar';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [MediaCard, SearchBar, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
medias = MEDIAS;
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
search = signal('');
selection = signal<Media[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visible = computed(() => {
const q = this.search().trim().toLowerCase();
return this.medias.filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
});
onBorrow(media: Media): void {
const list = this.selection();
if (list.some((m) => m.id === media.id) || list.length >= MAX_LOANS) return;
this.selection.set([...list, media]);
}
clear(): void {
this.selection.set([]);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, input, output } from '@angular/core';
import { Media, MAX_LOANS } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
readonly max = MAX_LOANS;
items = input<Media[]>([]);
cleared = output<void>();
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly remaining = computed(() => this.max - this.items().length);
readonly isFull = computed(() => this.remaining() === 0);
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ items().length }} / {{ max }} médias</p>
<p class="muted">Places restantes : {{ remaining() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" (click)="cleared.emit()">Vider</button>
</aside><header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<app-search-bar [(query)]="search" />
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of visible(); track m.id) {
<app-media-card [media]="m" (borrow)="onBorrow($event)" />
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="muted">Aucun média ne correspond à « {{ search() }} ».</p>
}
</section>
<app-selection-panel [items]="selection()" (cleared)="clear()" />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>/* Barre de recherche */
.search { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search input { flex: 1; min-width: 0; }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, model } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-search-bar',
templateUrl: './search-bar.html',
styleUrl: './search-bar.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SearchBar {
// model() : valeur lue et modifiable, synchronisée avec le parent
query = model('');
onInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
clear(): void {
this.query.set('');
}
}<div class="search">
<input [value]="query()" (input)="onInput($event)" placeholder="Rechercher un titre ou un auteur" aria-label="Rechercher un média">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" [disabled]="query() === ''" (click)="clear()">Effacer</button>
</div>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 13 · Composants en pratique
Le cycle de vie des composants
Comprendre les étapes de vie d'un composant, et placer le code au bon moment : chargement initial, changement d'entrée, nettoyage.
Glossaire du chapitre
- Cycle de vie
- Suite d’étapes qu’Angular traverse pour créer, mettre à jour et détruire un composant.
- ngOnInit
- Appelé une fois, juste après la création du composant et la réception de ses données.
- ngOnChanges
- Appelé à chaque fois qu’une donnée reçue du parent change.
- ngAfterViewInit
- Appelé quand le template du composant est affiché.
- ngOnDestroy
- Appelé juste avant la destruction du composant : moment idéal pour nettoyer un minuteur.
- DestroyRef
- Objet qui permet d’exécuter un nettoyage à la destruction, sans méthode
ngOnDestroy.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous observons le cycle de vie avec un minuteur de cuisson. Les extraits s'essaient dans un composant Minuteur, affiché ou retiré de App avec un @if.
Au programme : 13.1 Un composant a une vie · 13.2 ngOnInit : le démarrage · 13.3 ngOnDestroy et DestroyRef : nettoyer
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 13.3.A Un chronomètre qui s'arrêteBonus
Dans demo, créez un composant chrono :
- un signal
secondes(0au départ) augmenté de1toutes les secondes par unsetInterval; - un bouton Pause / Play : une méthode
basculer()arrête le minuteur (clearInterval) s'il tourne et le relance s'il est en pause ; - le minuteur doit aussi être arrêté quand le composant est détruit (
DestroyRef) ; - dans
App, affichez<app-chrono />dans un@if (visible)avec un bouton qui basculevisible, et vérifiez dans la console (unconsole.logdansonDestroy) que le nettoyage a lieu.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, DestroyRef, inject, signal } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-chrono',
templateUrl: './chrono.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Chrono {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
secondes = signal(0);
enPause = signal(false);
private id: ReturnType<typeof setInterval> | null = null;
constructor() {
this.demarrer();
// inject() : demande au conteneur une instance du service
inject(DestroyRef).onDestroy(() => {
this.arreter();
console.log('Chrono détruit');
});
}
basculer(): void {
if (this.enPause()) {
this.demarrer();
} else {
this.arreter();
}
}
private demarrer(): void {
if (this.id !== null) return; // évite de créer deux intervalles
this.enPause.set(false);
this.id = setInterval(() => this.secondes.update((s) => s + 1), 1000);
}
private arreter(): void {
clearInterval(this.id ?? undefined);
this.id = null;
this.enPause.set(true);
}
}<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ secondes() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="basculer()">
{{ enPause() ? 'Play' : 'Pause' }}
</button>Imports
Les imports de ce fichier. Tous viennent de @angular/core :
Component: le décorateur qui transforme une classe en composant.DestroyRef: le « crochet » qui permet d'exécuter du code à la destruction du composant.inject: la fonction qui demande à Angular une dépendance (iciDestroyRef).signal: la fonction qui crée une valeur réactive.
Le code
secondesetenPause:secondescompte les secondes écoulées,enPauseindique si le chrono est arrêté. Ce sont des signals, parce que l'écran doit suivre leurs changements.idGarde l'identifiant renvoyé parsetInterval. Il vautnullquand aucun minuteur ne tourne, ce qui permet de savoir s'il y en a un en cours.constructor()Démarre le chrono avecdemarrer(), puis enregistre le nettoyage avecinject(DestroyRef).onDestroy(...). À la destruction du composant,arreter()coupe le minuteur et la console affiche « Chrono détruit ».basculer()Appelée par le bouton. En pause, elle relance le chrono (demarrer()) ; sinon, elle l'arrête (arreter()).demarrer()Commence parif (this.id !== null) return;. Si un minuteur tourne déjà, on ne fait rien. Sans cette garde, deux clics rapprochés pourraient créer deux minuteurs qui compteraient deux fois plus vite, et l'un d'eux ne pourrait plus être arrêté (son identifiant serait perdu).arreter()Coupe le minuteur avecclearInterval, remetidànullpour quedemarrer()puisse le relancer plus tard, et passeenPauseàtrue. Le?? undefinedconvertitnullenundefined, carclearIntervaln'accepte pasnullen TypeScript.demarrer()etarreter()sontprivate: seul le composant les appelle. Le template n'a accès qu'àbasculer().
Dans le template :
{{ secondes() }}affiche le compteur (un signal se lit avec des parenthèses).(click)="basculer()"appelle la méthode au clic.{{ enPause() ? 'Play' : 'Pause' }}change le texte du bouton : « Play » quand le chrono est en pause, « Pause » quand il tourne.
Sans le nettoyage dans onDestroy, le message de console.log n'apparaîtrait jamais et le minuteur continuerait à incrémenter un composant qui n'existe plus.
Étape 10 L'horloge de la médiathèque
Ce qui est réalisé : La médiathèque est ouverte de 9 h à 19 h. Pour que le lecteur le sache sans chercher, nous ajoutons à l'en-tête de Médiathèque une horloge qui :
- affiche l'heure en direct ;
- indique si la médiathèque est ouverte ou fermée.
Fichiers concernés : src/app/library-clock/library-clock.ts · src/app/library-clock/library-clock.html · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 13
Quel hook est le bon endroit pour charger des données au démarrage ?
ngOnInit est appelé après la mise en place des entrées, ce qui en fait l'endroit idéal.
Pourquoi ne pas faire d'appels réseau dans le constructeur ?
Le constructeur ne sert qu'à recevoir les dépendances.
À quoi sert ngOnDestroy ou DestroyRef ?
C'est le moment de libérer ce qui a été lancé.
Que fait
inject(DestroyRef).onDestroy(() => clearInterval(id))?DestroyRef permet d'enregistrer le nettoyage au moment de la création.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// inject() : demande au conteneur une instance du service
inject(DestroyRef).onDestroy(() => {
clearInterval(id);
console.log('Horloge détruite, minuteur arrêté');
});<!-- {{ }} : affiche la valeur de la classe -->
<span class="muted">{{ now().toLocaleTimeString('fr-FR') }}</span>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (isOpen()) {
<span class="badge badge-ok">Ouvert</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Fermé</span>
} // signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
showClock = signal(true);
toggleClock(): void {
this.showClock.update((v) => !v);
} <!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" (click)="toggleClock()">
<!-- {{ }} : affiche la valeur de la classe -->
{{ showClock() ? "Masquer l'horloge" : "Afficher l'horloge" }}
</button>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (showClock()) {
<app-library-clock />
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 4 · 4 / 19
Services et injection
Chapitre 14 · Services et injection
Services et injection de dépendances
Sortir la logique métier des composants, la placer dans un service, et l'injecter là où elle est nécessaire.
Glossaire du chapitre
- Service
- Classe qui porte la logique partagée (données, appels API) et qui n’appartient à aucun écran.
- Injection de dépendances
- Angular fournit les services dont une classe a besoin, au lieu que la classe les crée elle-même.
- inject()
- Fonction qui demande au conteneur une instance d’un service.
- @Service()
- Décorateur récent qui déclare un service. Il remplace
@Injectable({ providedIn: 'root' }). - Conteneur d’injection
- Mécanisme interne d’Angular qui crée et garde les instances des services.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous gardons le carnet de recettes : les extraits utilisent un service de recettes, utilisé directement dans le composant racine App. Avant de créer le service, il faut des données : un modèle (la forme d'une recette) et une liste de recettes. Créez deux fichiers dans demo (clic droit sur src/app → New → Directory pour créer les dossiers models et data, puis New → File).
Au programme : 14.1 Pourquoi un service ? · 14.2 Créer un service · 14.3 inject() : demander un service · 14.4 L'injection par le constructeur · 14.5 Injecteurs et portée d'un service
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
4 exercices
Exercice 14.2.A Un service de chefsEssentiel
Dans demo, écrivez un petit service sur le modèle de RecetteService, sans encore l'utiliser dans un composant :
- créez le fichier
src/app/chef.service.ts(voir la section 14.2 pour le CLI et le renommage) avec la classeChefService, décorée par@Service(); - une propriété
private chefs: string[]qui contient'Mamie Jeanne','Léo'et'Papi René'; - une méthode
getAll()qui renvoie la liste des chefs ; - une méthode
getParInitiale(initiale: string)qui renvoie le premier chef dont le nom commence par cette lettre, ouundefineds'il n'y en a pas (indice :findetstartsWith, commegetByIdavecfind) ; - vérifiez que le projet compile (
ng serve, aucune erreur dans le terminal). Nous utiliserons les services dans un composant à la section suivante.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service } from '@angular/core';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ChefService {
private chefs: string[] = ['Mamie Jeanne', 'Léo', 'Papi René'];
getAll(): string[] {
return this.chefs;
}
getParInitiale(initiale: string): string | undefined {
return this.chefs.find((c) => c.startsWith(initiale));
}
}Le code
@Service()Déclare la classe comme service : une seule instance pour toute l'application, prête à être injectée.- La liste est
private: on ne la modifie pas depuis l'extérieur. Les composants passeront pargetAll()etgetParInitiale(). find((c) => c.startsWith(initiale))Renvoie le premier chef dont le nom commence par la lettre donnée, par exemple'Léo'pour'L'. Pour'Z', il renvoieundefined, et le type de retourstring | undefinedle dit explicitement.
Exercice 14.3.A Un service de favorisEssentiel
Dans demo, construisez un service qui garde la liste des recettes favorites :
- créez
src/app/favoris.service.ts(voir la section 14.2 pour le CLI et le renommage ; dans la section précédente,RecetteServicea été écrit de la même façon) avec la classeFavorisService, décorée par@Service(); - un signal
titres(tableau de textes, vide au départ) et uncomputednombrequi donne sa taille ; - une méthode
ajouter(titre: string)qui ajoute le titre, sauf s'il est déjà dans la liste ou s'il est vide ; - une méthode
retirer(titre: string)qui le retire (indice :filtergarde les éléments qui respectent une condition ; pour retirer un titre, on garde donc tous ceux qui sont différents de lui, etupdaterange la nouvelle liste dans le signal) ; - créez un composant
favorisqui injecte le service avecinject(), affichenombre(), la liste (avec@for), un champ de saisie lié parngModelet un bouton Ajouter. Chaque titre a un bouton Retirer.
Le composant ne doit contenir aucune logique de liste : tout passe par le service.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, computed, signal } from '@angular/core';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class FavorisService {
titres = signal<string[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
nombre = computed(() => this.titres().length);
ajouter(titre: string): void {
const propre = titre.trim();
if (propre === '' || this.titres().includes(propre)) return;
this.titres.update((liste) => [...liste, propre]);
}
retirer(titre: string): void {
this.titres.update((liste) => liste.filter((t) => t !== titre));
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FavorisService } from '../favoris.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-favoris',
imports: [FormsModule],
templateUrl: './favoris.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Favoris {
// inject() : demande au conteneur une instance du service
favoris = inject(FavorisService);
saisie = '';
ajouter(): void {
this.favoris.ajouter(this.saisie);
this.saisie = '';
}
}<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="saisie" placeholder="Titre d'une recette">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="ajouter()">Ajouter</button>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ favoris.nombre() }} favori(s)</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (titre of favoris.titres(); track titre) {
<li>{{ titre }} <button (click)="favoris.retirer(titre)">Retirer</button></li>
}
</ul>Comment la méthode retirer fait-elle disparaître un titre ? Elle ne modifie pas la liste : elle en fabrique une nouvelle, sans le titre, et la range dans le signal. Avec titres = ['Crêpes', 'Tarte', 'Soupe'] et retirer('Tarte') :
liste.filter((t) => t !== titre)teste chaque élémentt:'Crêpes' !== 'Tarte'est vrai, on garde ;'Tarte' !== 'Tarte'est faux, on écarte ;'Soupe' !== 'Tarte'est vrai, on garde.filterrenvoie le nouveau tableau['Crêpes', 'Soupe']. L'ancien n'est pas modifié.update((liste) => ...)prend l'ancienne liste, exécute la fonction et range le résultat dans le signal : l'écran se met à jour.
filter se lit « garder ceux qui respectent la condition » : pour retirer un élément, on garde donc tous les autres. On ne modifie pas le tableau existant (par exemple avec splice), car le signal ne verrait pas de changement : c'est la même liste en mémoire. Limite de cette version : si deux titres étaient identiques, ils disparaîtraient ensemble (ici, ajouter empêche les doublons).
Le composant ne fait que relayer : la règle « pas de doublon, pas de titre vide » vit dans le service. Si un deuxième composant injecte FavorisService, il verra la même liste, puisque @Service() crée une seule instance pour toute l'application.
Exercice 14.3.B Un service qui en utilise un autreEssentiel
Un service peut lui aussi demander un autre service avec inject(). Dans demo, ajoutez un journal des actions aux favoris (exercice 14.3.A) :
- créez
src/app/journal.service.tsavec la classeJournalService(@Service()), un signallignes(tableau de textes) et une méthodenoter(message: string)qui ajoute une ligne ; - dans
FavorisService, injectez le journal avecinject(JournalService); - appelez
noterquand un favori est ajouté (« Ajout : … ») et quand il est retiré (« Retrait : … ») ; - dans le composant
favoris, injectez aussiJournalServiceet affichez ses lignes dans une liste@for.
Aucun fichier ne contient de new : c'est Angular qui fournit chaque service.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, signal } from '@angular/core';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class JournalService {
lignes = signal<string[]>([]);
noter(message: string): void {
this.lignes.update((liste) => [...liste, message]);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, computed, inject, signal } from '@angular/core';
import { JournalService } from './journal.service';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class FavorisService {
// inject() : demande au conteneur une instance du service
private journal = inject(JournalService);
titres = signal<string[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
nombre = computed(() => this.titres().length);
ajouter(titre: string): void {
const propre = titre.trim();
if (propre === '' || this.titres().includes(propre)) return;
this.titres.update((liste) => [...liste, propre]);
this.journal.noter(`Ajout : ${propre}`);
}
retirer(titre: string): void {
this.titres.update((liste) => liste.filter((t) => t !== titre));
this.journal.noter(`Retrait : ${titre}`);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FavorisService } from '../favoris.service';
import { JournalService } from '../journal.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-favoris',
imports: [FormsModule],
templateUrl: './favoris.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Favoris {
// inject() : demande au conteneur une instance du service
favoris = inject(FavorisService);
journal = inject(JournalService);
saisie = '';
ajouter(): void {
this.favoris.ajouter(this.saisie);
this.saisie = '';
}
}<!-- Liaison à double sens : le champ et la classe restent synchronisés -->
<input [(ngModel)]="saisie" placeholder="Titre d'une recette">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="ajouter()">Ajouter</button>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ favoris.nombre() }} favori(s)</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (titre of favoris.titres(); track titre) {
<li>{{ titre }} <button (click)="favoris.retirer(titre)">Retirer</button></li>
}
</ul>
<h3>Journal</h3>
<ul>
@for (ligne of journal.lignes(); track $index) {
<li>{{ ligne }}</li>
}
</ul>Pour le journal, on écrit track $index : les lignes sont de simples textes sans identifiant, donc Angular les reconnaît par leur position (voir la section 9.3). Pour les favoris, le titre lui-même sert de clé.
Le composant et FavorisService reçoivent la même instance de JournalService : une ligne écrite par le service apparaît donc dans la liste affichée par le composant. L'ordre des déclarations n'a aucune importance : Angular crée chaque service au moment où quelqu'un le demande.
Exercice 14.5.A Un ou deux compteurs de visites ?Essentiel
Dans demo :
- Créez un service
CompteurServiceavec un signal privétotal(initialisé à 0) et deux méthodesincrementer()etvaleur(). - Créez deux composants
compteur-aetcompteur-bqui injectent ce service et affichent sa valeur avec un bouton +1. - Cliquez sur le bouton de l'un, puis de l'autre : que constatez-vous ?
- Ajoutez
providers: [CompteurService]au composantcompteur-b(si vous l'avez déjà écrit dans la démo ci-dessus, commencez par retirer cette ligne pour observer le partage), et comparez. - Affichez les deux compteurs dans
Appet notez les valeurs après chaque clic.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, signal } from '@angular/core';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class CompteurService {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
private total = signal(0);
valeur(): number {
return this.total();
}
incrementer(): void {
this.total.update((t) => t + 1);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { CompteurService } from '../compteur.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-compteur-a',
templateUrl: './compteur-a.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class CompteurA {
// inject() : demande au conteneur une instance du service
compteur = inject(CompteurService);
}<!-- {{ }} : affiche la valeur de la classe -->
<p>A : {{ compteur.valeur() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="compteur.incrementer()">+1</button>Étape 3 : compteur-b sans providers. Dans src/app/compteur-b/compteur-b.ts, le composant injecte le service, comme compteur-a :
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { CompteurService } from '../compteur.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-compteur-b',
templateUrl: './compteur-b.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class CompteurB {
// inject() : demande au conteneur une instance du service
compteur = inject(CompteurService);
}<!-- {{ }} : affiche la valeur de la classe -->
<p>B : {{ compteur.valeur() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="compteur.incrementer()">+1</button>Afficher les deux compteurs. Dans src/app/app.ts, importez les deux composants et ajoutez-les à imports (gardez les autres entrées déjà présentes) :
import { CompteurA } from './compteur-a/compteur-a';
import { CompteurB } from './compteur-b/compteur-b';
// dans le décorateur @Component :
imports: [CompteurA, CompteurB],Dans src/app/app.html, affichez-les :
<app-compteur-a />
<app-compteur-b />Ce que vous voyez, sans providers (une seule instance partagée). Les deux composants affichent la même valeur :
| Action | Affichage de A | Affichage de B |
|---|---|---|
| Au chargement | A : 0 | B : 0 |
| Un clic sur +1 de A | A : 1 | B : 1 |
| Un clic sur +1 de B | A : 2 | B : 2 |
Étape 4 : compteur-b avec providers. Ajoutez la ligne providers: [CompteurService] dans le décorateur de compteur-b :
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { CompteurService } from '../compteur.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-compteur-b',
providers: [CompteurService], // nouveau : une instance propre à compteur-b
templateUrl: './compteur-b.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class CompteurB {
// inject() : demande au conteneur une instance du service
compteur = inject(CompteurService);
}Le HTML de compteur-b, app.ts et app.html ne changent pas. Seule la ligne providers est ajoutée : providers: [CompteurService] demande à Angular de créer une nouvelle instance du service pour ce composant. inject(CompteurService) reçoit alors cette instance locale, et non celle de la racine.
Ce que vous voyez, avec providers (deux instances séparées).
| Action | Affichage de A (instance racine) | Affichage de B (instance locale) |
|---|---|---|
| Au chargement | A : 0 | B : 0 |
| Un clic sur +1 de A | A : 1 | B : 0 |
| Un clic sur +1 de A | A : 2 | B : 0 |
| Un clic sur +1 de B | A : 2 | B : 1 |
Deux situations :
- sans
providers, les deux composants partagent la même instance : cliquer sur l'un fait avancer l'autre ; - avec
providers: [CompteurService]surcompteur-b, il reçoit sa propre instance : ses compteurs deviennent indépendants.
C'est la preuve concrète de la différence entre une instance racine et une instance locale.
Étape 11 Le service de médias
Ce qui est réalisé : Dans cette étape, nous créons un MediaService qui :
- fournit les médias ;
- retrouve un média par son identifiant ;
- réalise la recherche.
Fichiers concernés : src/app/media.service.ts · src/app/app.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 14
Quel est le rôle principal d'un service ?
Un service contient la logique réutilisable, que les composants demandent.
Que fait
@Service()par défaut ?Par défaut, le service est un singleton partagé.
Comment un composant obtient-il un service avec la syntaxe moderne ?
inject()demande l'instance à l'injecteur d'Angular.Que se passe-t-il si un composant déclare
providers: [CounterService]?Un provider local crée une instance propre au composant et à ses enfants.
Peut-on mettre une dépendance dans le constructeur d'un service décoré par
@Service() ?Le décorateur
@Serviceattend une injection parinject()dans le corps de la classe.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service } from '@angular/core';
import { MEDIAS } from './data/medias';
import { Media } from './models/media';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
private medias: Media[] = MEDIAS;
getAll(): Media[] {
return this.medias;
}
getById(id: number): Media | undefined {
return this.medias.find((m) => m.id === id);
}
search(query: string): Media[] {
const q = query.trim().toLowerCase();
return this.medias.filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, signal } from '@angular/core';
import { LibraryClock } from './library-clock/library-clock';
import { MediaCard } from './media-card/media-card';
import { MediaService } from './media.service';
import { Media, MAX_LOANS } from './models/media';
import { SearchBar } from './search-bar/search-bar';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [LibraryClock, MediaCard, SearchBar, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
search = signal('');
selection = signal<Media[]>([]);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visible = computed(() => this.mediaService.search(this.search()));
onBorrow(media: Media): void {
const list = this.selection();
if (list.some((m) => m.id === media.id) || list.length >= MAX_LOANS) return;
this.selection.set([...list, media]);
}
clear(): void {
this.selection.set([]);
}
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 15 · Services et injection
Partager des données entre composants
Faire partager un état à plusieurs composants (la sélection d'emprunts, par exemple) grâce à un service qui porte des signals.
Glossaire du chapitre
- État partagé
- Donnée utilisée par plusieurs composants, conservée dans un service.
- Singleton
- Une seule instance d’un service pour toute l’application.
- asReadonly()
- Donne une version en lecture seule d’un signal : les composants peuvent le lire, pas le modifier.
- Encapsulation de l’état
- Seul le service modifie son état, par des méthodes comme
add().
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous partageons un état entre deux composants avec un petit système de notifications : un composant déclenche un message, un autre l'affiche. Cet exemple n'a rien à voir avec la médiathèque.
Au programme : 15.1 Le problème des composants isolés · 15.2 Un service qui porte l'état · 15.3 Utiliser le service dans plusieurs composants
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 15.3.A Un thème partagéEssentiel
Dans demo :
- Créez un service
ThemeServiceavec un signal privésombre(booléen,false), exposé en lecture seule, et une méthodebasculer(). - Créez un composant
bouton-theme(un bouton Basculer le thème) et un composantfondqui affiche un<div>portant la classesombrequand le thème est sombre. - Ajoutez au CSS :
.sombre { background: #222; color: white; }. Affichez les deux composants dansApp: le bouton doit changer le fond de l'autre composant.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, signal } from '@angular/core';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ThemeService {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
private readonly etat = signal(false);
readonly sombre = this.etat.asReadonly();
basculer(): void {
this.etat.update((v) => !v);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { ThemeService } from '../theme.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-fond',
template: `<div [class.sombre]="theme.sombre()">Le thème est partagé.</div>`,
styles: `.sombre { background: #222; color: white; }`,
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Fond {
// inject() : demande au conteneur une instance du service
theme = inject(ThemeService);
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { ThemeService } from '../theme.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-bouton-theme',
template: `<button (click)="theme.basculer()">Basculer le thème</button>`,
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class BoutonTheme {
// inject() : demande au conteneur une instance du service
theme = inject(ThemeService);
}Les deux composants ne se connaissent pas : ils partagent la même instance de ThemeService. Le signal modifiable etat reste privé, et sombre = this.etat.asReadonly() en est la vue en lecture seule : les composants lisent theme.sombre() mais ne peuvent pas appeler set(). Seule la méthode basculer() peut changer le thème (voir la section 15.2).
Étape 12 La sélection partagée
Ce qui est réalisé : Dans cette étape, nous confions la sélection à un SelectionService :
- la carte y ajoute un média ;
- le panneau l'affiche ;
- un petit badge dans l'en-tête indique le nombre de médias choisis.
Fichiers concernés : src/app/selection.service.ts · src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html · src/app/selection-badge/selection-badge.ts · src/app/selection-badge/selection-badge.html · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 15
Pourquoi placer l'état de la sélection dans un service plutôt que dans un composant ?
Un service est une source commune que plusieurs composants peuvent lire.
Que fait
this.items.asReadonly()?Les autres parties de l'application peuvent lire la valeur, mais pas la modifier.
Comment le badge se met-il à jour quand la sélection change ?
Les signals et computed transmettent le changement à tous les lecteurs.
Dans
add(), pourquoi renvoie-t-on un nouveau tableau plutôt que de modifier l'ancien avec push ?Un nouveau tableau ou un nouvel objet signale clairement la modification.
Quel est le rôle d'un signal privé dans un service ?
Le privé impose de passer par les méthodes du service pour modifier l'état.
Code de Médiathèque à la fin du chapitre (9 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, computed, signal } from '@angular/core';
import { Media, MAX_LOANS } from './models/media';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionService {
private readonly items = signal<Media[]>([]);
readonly list = this.items.asReadonly();
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly count = computed(() => this.items().length);
readonly remaining = computed(() => MAX_LOANS - this.items().length);
readonly isFull = computed(() => this.remaining() === 0);
has(id: number): boolean {
return this.items().some((m) => m.id === id);
}
add(media: Media): void {
if (this.isFull() || this.has(media.id)) return;
this.items.update((list) => [...list, media]);
}
remove(id: number): void {
this.items.update((list) => list.filter((m) => m.id !== id));
}
clear(): void {
this.items.set([]);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, booleanAttribute, inject, input } from '@angular/core';
import { Highlight } from '../highlight';
import { Media } from '../models/media';
import { SelectionService } from '../selection.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
imports: [Highlight],
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
// input() : valeur fournie par le parent ; on la lit avec ()
compact = input(false, { transform: booleanAttribute });
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
borrow(): void {
this.selection.add(this.media());
}
}<article class="card" appHighlight
[class.card--out]="!media().available"
[class.card--top]="(media().rating ?? 0) >= 4.5">
<!-- {{ }} : affiche la valeur de la classe -->
<h2>{{ media().title }}</h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (!compact()) {
<p class="author">{{ media().author }} · {{ media().year }}</p>
}
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (media().type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ media().type }}
</span>
@if (media().available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (media().year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
<button class="btn"
[disabled]="!media().available || selection.has(media().id) || selection.isFull()"
(click)="borrow()">
{{ selection.has(media().id) ? 'Dans ma sélection' : 'Emprunter' }}
</button>
</article>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { MAX_LOANS } from '../models/media';
import { SelectionService } from '../selection.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
protected max = MAX_LOANS;
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} / {{ max }} médias</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<li>{{ m.title }} <button class="btn btn-secondary" (click)="selection.remove(m.id)">✕</button></li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li class="muted">Aucun média choisi.</li>
}
</ul>
<button class="btn btn-secondary" (click)="selection.clear()">Vider</button>
</aside>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { SelectionService } from '../selection.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-badge',
templateUrl: './selection-badge.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionBadge {
// inject() : demande au conteneur une instance du service
selection = inject(SelectionService);
}<!-- {{ }} : affiche la valeur de la classe -->
<span class="badge">Ma sélection : {{ selection.count() }}</span>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, signal } from '@angular/core';
import { LibraryClock } from './library-clock/library-clock';
import { MediaCard } from './media-card/media-card';
import { MediaService } from './media.service';
import { SearchBar } from './search-bar/search-bar';
import { SelectionBadge } from './selection-badge/selection-badge';
import { SelectionPanel } from './selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [LibraryClock, MediaCard, SearchBar, SelectionBadge, SelectionPanel],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
search = signal('');
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visible = computed(() => this.mediaService.search(this.search()));
}<header class="site-header">
<div class="container site-header__inner">
<a class="logo" href="#">Médiathèque</a>
<nav class="site-nav">
<a href="#">Catalogue</a>
<a href="#">Mes emprunts</a>
<app-selection-badge />
<app-library-clock />
</nav>
</div>
</header>
<main class="container">
<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
</section>
<app-search-bar [(query)]="search" />
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of visible(); track m.id) {
<app-media-card [media]="m" />
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="muted">Aucun média ne correspond à « {{ search() }} ».</p>
}
</section>
<app-selection-panel />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 5 · 5 / 19
Navigation
Chapitre 16 · Navigation
Mise en place du routage
Associer des URL à des écrans, afficher la bonne page selon l'adresse, naviguer avec des liens et avec du code, et gérer les pages introuvables.
Glossaire du chapitre
- Route
- Association entre une URL et un composant à afficher.
- Routeur
- Service qui lit l’URL et affiche le bon composant.
- router-outlet
- Emplacement dans le template où s’affiche la page de la route active.
- routerLink
- Lien vers une autre route, sans rechargement de la page.
- Route joker
- Route
**qui attrape toutes les URL inconnues, pour afficher une page 404.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous construisons un mini-site de recettes à trois pages : accueil, recettes et introuvable (ng generate component … pour chacune). Les extraits s'essaient dans ce projet, sans toucher à Médiathèque.
Au programme : 16.1 Le principe du routage · 16.2 provideRouter et la liste des routes · 16.3 router-outlet : l'emplacement de l'écran · 16.4 routerLink et routerLinkActive · 16.5 Naviguer par le code · 16.6 La page 404 : la route joker
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 16.6.A Une page « À propos »Essentiel
Dans demo :
- Créez un composant
aproposavec un titreÀ proposet une phrase de présentation. - Ajoutez la route
aproposavant la route joker, et un lien dans le menu avec la classeactifsur la page courante. - Sur la page
apropos, ajoutez un bouton Retour à l'accueil qui navigue par le code avecRouter(navigateByUrl('/')). - Testez l'adresse
/n-importe-quoi: la pageintrouvabledoit s'afficher.
Votre code à compléter
import { Routes } from '@angular/router';
import { Home } from './home/home';
export const routes: Routes = [
{ path: '', component: Home },
// TODO : ajoutez les routes « catalogue », « emprunts », « contact », puis la route joker « ** » vers NotFound
];Voir la solution
export const routes: Routes = [
{ path: '', component: Accueil },
{ path: 'recettes', component: Recettes },
{ path: 'apropos', component: Apropos },
{ path: '**', component: Introuvable },
];// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-apropos',
template: `
<h1>À propos</h1>
<p>Un petit carnet de recettes pour s'entraîner.</p>
<button (click)="retour()">Retour à l'accueil</button>
`,
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Apropos {
// inject() : demande au conteneur une instance du service
private router = inject(Router);
retour(): void {
// navigateByUrl : navigation déclenchée depuis la classe
this.router.navigateByUrl('/');
}
}Dans app.html, le lien ajouté est <a routerLink="/apropos" routerLinkActive="actif">À propos</a>. La route joker reste en dernière position, sinon /apropos afficherait la page 404.
Étape 13 Un site à plusieurs pages
Ce qui est réalisé : Dans cette étape, nous en faisons un site à plusieurs pages :
- un accueil ;
- le catalogue avec sa recherche ;
- la page « Mes emprunts » avec la sélection ;
- une page contact ;
- une page 404.
Fichiers concernés : src/app/app.routes.ts · src/app/home/home.ts · src/app/home/home.html · src/app/catalogue/catalogue.ts · src/app/catalogue/catalogue.html · src/app/loans/loans.ts · src/app/loans/loans.html · src/app/contact/contact.ts · src/app/contact/contact.html · src/app/not-found/not-found.ts · src/app/not-found/not-found.html · src/app/app.ts · src/app/app.html · src/app/app.css
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 16
Quel fichier déclare les routes d'une application Angular ?
Le tableau Routes associe chaque chemin à un composant.
Où s'affiche le composant de la route courante ?
router-outlet est l'emplacement réservé au composant routé.
Pourquoi utiliser routerLink plutôt que href pour un lien interne ?
Un href classique recharge la page et fait perdre l'état de l'application.
Où doit-on placer la route joker
**?Les routes sont testées dans l'ordre : la joker doit venir après toutes les autres.
Comment naviguer vers /catalogue depuis le code d'un composant ?
Le service Router permet la navigation programmatique.
Code de Médiathèque à la fin du chapitre (14 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
import { Routes } from '@angular/router';
import { Home } from './home/home';
import { Catalogue } from './catalogue/catalogue';
import { Loans } from './loans/loans';
import { Contact } from './contact/contact';
import { NotFound } from './not-found/not-found';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'catalogue', component: Catalogue },
{ path: 'emprunts', component: Loans },
{ path: 'contact', component: Contact },
{ path: '**', component: NotFound },
];// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-home',
imports: [RouterLink],
templateUrl: './home.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Home {}<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/catalogue">Voir le catalogue</a>
</section>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, signal } from '@angular/core';
import { MediaService } from '../media.service';
import { MediaCard } from '../media-card/media-card';
import { SearchBar } from '../search-bar/search-bar';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-catalogue',
imports: [MediaCard, SearchBar],
templateUrl: './catalogue.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Catalogue {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
search = signal('');
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visible = computed(() => this.mediaService.search(this.search()));
}<h1>Catalogue</h1>
<app-search-bar [(query)]="search" />
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of visible(); track m.id) {
<app-media-card [media]="m" />
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="muted">Aucun média ne correspond à « {{ search() }} ».</p>
}
</section>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { SelectionPanel } from '../selection-panel/selection-panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-loans',
imports: [SelectionPanel],
templateUrl: './loans.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Loans {}<h1>Mes emprunts</h1>
<app-selection-panel />// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-contact',
templateUrl: './contact.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Contact {}<h1>Contact</h1>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-not-found',
imports: [RouterLink],
templateUrl: './not-found.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class NotFound {}<h1>Page introuvable</h1>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/">Retour à l'accueil</a>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { LibraryClock } from './library-clock/library-clock';
import { SelectionBadge } from './selection-badge/selection-badge';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive, SelectionBadge, LibraryClock],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {}<header class="site-header">
<div class="container site-header__inner">
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="logo" routerLink="/">Médiathèque</a>
<nav class="site-nav">
<a routerLink="/" routerLinkActive="actif" [routerLinkActiveOptions]="{ exact: true }">Accueil</a>
<a routerLink="/catalogue" routerLinkActive="actif">Catalogue</a>
<a routerLink="/emprunts" routerLinkActive="actif">Mes emprunts</a>
<a routerLink="/contact" routerLinkActive="actif">Contact</a>
<app-selection-badge />
<app-library-clock />
</nav>
</div>
</header>
<main class="container">
<!-- router-outlet : emplacement où s’affiche la page de la route active -->
<router-outlet />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>.site-nav a.actif { color: var(--primary); border-bottom: 2px solid var(--primary); }Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 17 · Navigation
Paramètres de route et routes imbriquées
Passer un identifiant dans l'URL, récupérer ses valeurs dans un composant, et organiser des pages en sous-pages.
Glossaire du chapitre
- Paramètre de route
- Partie variable de l’URL, écrite
:id, par exemple/médias/42. - Query params
- Paramètres après le
?de l’URL, par exemple?q=clavier. - Route imbriquée
- Route enfant, affichée à l’intérieur de la page de sa route parente.
- withComponentInputBinding
- Option qui envoie les paramètres de route directement dans des
input(). - redirectTo
- Redirection automatique vers une autre route.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous ajoutons au mini-site de recettes une fiche de recette (recette-detail), puis un espace chef. Cette partie reste dans le carnet de recettes. Le service RecetteService du chapitre 14 fournit getAll() et getById() ; nous y ajouterons getBySlug().
Au programme : 17.1 Les paramètres de route · 17.2 Recevoir le paramètre dans le composant · 17.3 Les paramètres de requête (query params) · 17.4 Les routes imbriquées · 17.5 Les redirections
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
2 exercices
Exercice 17.3.A Un champ de recherche qui écrit dans l'adresseEssentiel
Un lien écrit en dur, ce n'est pas une vraie recherche. Dans une application réelle, l'utilisateur tape son mot, et c'est le code qui met à jour l'adresse. Dans demo :
- Dans
recettes.ts, injectez leRouter(inject(Router)) et ajoutez une méthodechercher(mot: string)qui appellethis.router.navigate([], { queryParams: { q: mot || null } }). Le tableau vide[]signifie « rester sur la même page », etnullretire le paramètre de l'adresse quand le champ est vide. - Dans
recettes.html, ajoutez au-dessus de la liste un champ<input>qui affiche la valeur courante deq([value]="q() ?? ''") et appellechercher($event.target.value)à chaque frappe ((input)). Si TypeScript refuse$event.target.value, déclarez une variable de template :<input #champ [value]="q() ?? ''" (input)="chercher(champ.value)" />. - Testez : en tapant
tar, l'adresse devient/recettes?q=taret la liste ne garde que « Tarte Tatin ». Effacez le champ : le?q=disparaît et tout s'affiche. Rechargez la page avec un mot saisi : la recherche est conservée, puisqu'elle est dans l'adresse.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { RecetteService } from '../recette.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-recettes',
imports: [RouterLink],
templateUrl: './recettes.html',
styleUrl: './recettes.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Recettes {
// inject() : demande au conteneur une instance du service
private recetteService = inject(RecetteService);
private router = inject(Router);
recettes = this.recetteService.getAll();
q = input<string>();
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visibles = computed(() => {
const mot = (this.q() ?? '').toLowerCase();
return this.recettes.filter((r) => r.titre.toLowerCase().includes(mot));
});
chercher(mot: string): void {
this.router.navigate([], { queryParams: { q: mot || null } });
}
}<input #champ [value]="q() ?? ''" (input)="chercher(champ.value)" placeholder="Chercher une recette" />
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (r of visibles(); track r.id) {
<li>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a [routerLink]="['/recettes', r.slug]">{{ r.titre }}</a>
</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucune recette ne correspond.</li>
}
</ul>Le code
#champDonne un nom au champ de saisie dans le template :champ.valueest le texte actuellement tapé.(input)="chercher(champ.value)"À chaque frappe, la méthodechercher()s'exécute.this.router.navigate([], { queryParams: { q: mot || null } })Navigue vers la page courante ([]) en changeant seulement les paramètres de requête.mot || nulldonnenullquand le champ est vide, et Angular retire alors?q=de l'adresse.- L'adresse change, donc
qchange (grâce àwithComponentInputBinding()), doncvisiblesse recalcule, donc la liste se met à jour. Le champ ne filtre pas lui-même : l'adresse est la source de vérité.
Exercice 17.4.A Des recettes à parcourirEssentiel
Dans demo, en repartant de la page recette-detail construite aux sections 17.1 et 16.2 :
- Ajoutez un lien ← Retour vers
/recettes(importezRouterLinkdansRecetteDetail). - Ajoutez deux
computeddansrecette-detail.ts:precedenteetsuivante. À partir de la recette affichée, ils retrouvent avecRecetteService.getById()la recette d'identifiantid - 1etid + 1(le slug sert à trouver la recette courante, l'idà trouver ses voisines). - Dans le template, affichez un lien vers la recette précédente et un lien vers la suivante, seulement si elles existent (
@if (precedente(); as p)). - Testez
/recettes/salade-de-chevre-chaud(pas de précédente),/recettes/mochi-a-la-fraise(pas de suivante) et/recettes/nimporte-quoi(« Recette introuvable »).
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { RecetteService } from '../recette.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-recette-detail',
imports: [RouterLink],
templateUrl: './recette-detail.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class RecetteDetail {
// input.required : valeur obligatoire, fournie par le parent
slug = input.required<string>();
// inject() : demande au conteneur une instance du service
private recetteService = inject(RecetteService);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
recette = computed(() => this.recetteService.getBySlug(this.slug()));
precedente = computed(() => {
const r = this.recette();
return r ? this.recetteService.getById(r.id - 1) : undefined;
});
suivante = computed(() => {
const r = this.recette();
return r ? this.recetteService.getById(r.id + 1) : undefined;
});
}<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/recettes">← Retour</a>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (recette(); as r) {
<!-- {{ }} : affiche la valeur de la classe -->
<h1>{{ r.titre }}</h1>
<p>Prête en {{ r.duree }} minutes.</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<p>Recette introuvable.</p>
}
@if (precedente(); as p) {
<a [routerLink]="['/recettes', p.slug]">← {{ p.titre }}</a>
}
@if (suivante(); as s) {
<a [routerLink]="['/recettes', s.slug]">{{ s.titre }} →</a>
}Quand on passe de /recettes/crepes à /recettes/tarte-tatin, Angular réutilise le même composant : seul slug change, et les computed se recalculent seuls. C'est un cas où lire le paramètre avec input() est bien plus simple que l'ancien ActivatedRoute.
Étape 14 La fiche d'un média et l'espace bibliothécaire
Ce qui est réalisé : Dans cette étape, nous ajoutons :
- la fiche d'un média (une adresse par média, comme
/catalogue/3) ; - un lien de recherche pré-rempli (
/catalogue?q=dune) ; - l'espace bibliothécaire avec ses deux sous-pages.
Fichiers concernés : src/app/app.config.ts · src/app/media-detail/media-detail.ts · src/app/media-detail/media-detail.html · src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/catalogue/catalogue.ts · src/app/home/home.html · src/app/admin/admin-layout/admin-layout.ts · src/app/admin/admin-layout/admin-layout.html · src/app/admin/dashboard/dashboard.html · src/app/admin/admin-medias/admin-medias.html · src/app/app.routes.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 17
Comment déclare-t-on un paramètre
iddans une route ?Dans Angular, le paramètre commence par deux-points, comme dans
catalogue/:id.Quel est le type d'un paramètre de route reçu dans un
input()?Un paramètre d'URL est toujours du texte : il faut le convertir si nécessaire.
Que fait
withComponentInputBinding()?Les paramètres d'URL arrivent dans les entrées du même nom.
Quel est le rôle de
<router-outlet />dans un composant parent d'une route imbriquée ?C'est l'emplacement réservé aux enfants, comme pour l'application principale.
À quoi sert
pathMatch: 'full'dans une redirection ?Sans pathMatch full, la redirection vide capturerait toutes les URL.
Code de Médiathèque à la fin du chapitre (12 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// provideRouter : active le routeur avec la liste des routes
provideRouter(routes, withComponentInputBinding()),
],
};// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MediaService } from '../media.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-detail',
imports: [RouterLink],
templateUrl: './media-detail.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaDetail {
// input.required : valeur obligatoire, fournie par le parent
id = input.required<string>();
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
media = computed(() => this.mediaService.getById(Number(this.id())));
}<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/catalogue">← Retour au catalogue</a>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (media(); as m) {
<!-- {{ }} : affiche la valeur de la classe -->
<h1>{{ m.title }}</h1>
<p>{{ m.author }} · {{ m.year }} · {{ m.genre }}</p>
<p>
<span class="badge">{{ m.type }}</span>
@if (m.available) { <span class="badge badge-ok">Disponible</span> }
<!-- @else : affiché quand la condition du @if est fausse -->
@else { <span class="badge badge-bad">Indisponible</span> }
</p>
<p>Note : {{ m.rating !== undefined ? m.rating + ' / 5' : 'Pas encore noté' }}</p>
<p>Prêt de {{ m.loanDays }} jours.</p>
} @else {
<p>Média introuvable.</p>
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, booleanAttribute, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Highlight } from '../highlight';
import { Media } from '../models/media';
import { SelectionService } from '../selection.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
imports: [Highlight, RouterLink],
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
// input() : valeur fournie par le parent ; on la lit avec ()
compact = input(false, { transform: booleanAttribute });
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
borrow(): void {
this.selection.add(this.media());
}
}<article class="card" appHighlight
[class.card--out]="!media().available"
[class.card--top]="(media().rating ?? 0) >= 4.5">
<!-- routerLink : lien interne, sans rechargement de la page -->
<h2><a [routerLink]="['/catalogue', media().id]">{{ media().title }}</a></h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (!compact()) {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="author">{{ media().author }} · {{ media().year }}</p>
}
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (media().type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ media().type }}
</span>
@if (media().available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (media().year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
<button class="btn"
[disabled]="!media().available || selection.has(media().id) || selection.isFull()"
(click)="borrow()">
{{ selection.has(media().id) ? 'Dans ma sélection' : 'Emprunter' }}
</button>
</article>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, input, linkedSignal } from '@angular/core';
import { MediaService } from '../media.service';
import { MediaCard } from '../media-card/media-card';
import { SearchBar } from '../search-bar/search-bar';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-catalogue',
imports: [MediaCard, SearchBar],
templateUrl: './catalogue.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Catalogue {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
q = input<string>(); // reçoit ?q=... dans l'adresse
search = linkedSignal(() => this.q() ?? ''); // modifiable, mais repart de q
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
visible = computed(() => this.mediaService.search(this.search()));
}<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/catalogue">Voir le catalogue</a>
<a routerLink="/catalogue" [queryParams]="{ q: 'dune' }">Chercher Dune</a>
</section>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-admin-layout',
imports: [RouterLink, RouterOutlet],
templateUrl: './admin-layout.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AdminLayout {}<h1>Espace bibliothécaire</h1>
<nav>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/admin">Tableau de bord</a>
<a routerLink="/admin/medias">Gérer les médias</a>
</nav>
<!-- router-outlet : emplacement où s’affiche la page de la route active -->
<router-outlet /><h2>Tableau de bord</h2><h2>Gestion des médias</h2>import { Routes } from '@angular/router';
import { Home } from './home/home';
import { Catalogue } from './catalogue/catalogue';
import { MediaDetail } from './media-detail/media-detail';
import { Loans } from './loans/loans';
import { Contact } from './contact/contact';
import { AdminLayout } from './admin/admin-layout/admin-layout';
import { Dashboard } from './admin/dashboard/dashboard';
import { AdminMedias } from './admin/admin-medias/admin-medias';
import { NotFound } from './not-found/not-found';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'catalogue', component: Catalogue },
{ path: 'catalogue/:id', component: MediaDetail },
{ path: 'emprunts', component: Loans },
{ path: 'contact', component: Contact },
{
path: 'admin',
component: AdminLayout,
children: [
{ path: '', component: Dashboard },
{ path: 'medias', component: AdminMedias },
],
},
{ path: '**', component: NotFound },
];Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 18 · Navigation
Les guards : protéger les routes
Empêcher l'accès à une page selon une condition (utilisateur connecté, droits), et rediriger proprement.
Glossaire du chapitre
- Garde (guard)
- Fonction qui décide si une route peut être ouverte.
- CanActivateFn
- Type d’une garde fonctionnelle qui protège l’accès à une route.
- canMatch
- Garde qui décide si une route doit être prise en compte du tout.
- UrlTree
- Adresse de redirection renvoyée par une garde, créée avec
createUrlTree. - Authentification
- Vérifier qui est connecté.
- Autorisation
- Vérifier ce que la personne connectée a le droit de faire.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous protégeons l'espace chef du mini-site de recettes avec un guard. Les extraits utilisent un petit service de session, sans rapport avec Médiathèque.
Au programme : 18.1 Protéger une route · 18.2 Un guard fonctionnel avec canActivate · 18.3 canMatch : empêcher le chargement · 18.4 Rediriger proprement avec UrlTree
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 18.4.A Sortir et afficher la personne connectéeBonus
Cet exercice prolonge la démo : il suppose que vous avez fait la section 18.2, donc que SessionService, connecte.guard.ts et le composant entrer existent, et que la route entrer figure dans app.routes.ts. Pour vous connecter, ouvrez http://localhost:4200/entrer : la page affiche le bouton Entrer comme Ana, qui ouvre /chef. Si cette page est vide ou introuvable, revenez à la section 18.2 : c'est qu'une étape (le composant entrer ou sa route) manque.
Dans demo :
- Dans
ChefLayout(src/app/chef/chef-layout/chef-layout.ts), injectezSessionServiceavecinject()et gardez-le dans une propriétésession. - Dans
chef-layout.html, affichez « Connecté : Ana » avecsession.nom(). - Ajoutez un bouton Sortir qui appelle une méthode
sortir(): elle appellesession.sortir(), puis navigue vers/entrer. - Vérifiez : ouvrez
/entrer, cliquez sur Entrer comme Ana (vous arrivez sur/chefet voyez « Connecté : Ana »), puis cliquez sur Sortir (retour sur/entrer). Enfin, essayez d'aller sur/chef: vous devez être renvoyé sur/entrer.
Attention : la « connexion » n'est gardée qu'en mémoire. Si vous rechargez la page (F5) ou tapez une adresse dans la barre du navigateur, l'application redémarre et vous êtes de nouveau déconnecté. Pour tester la navigation en étant connecté, passez par les liens et les boutons de l'application.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { SessionService } from '../../session.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-chef-layout',
imports: [RouterLink, RouterOutlet],
templateUrl: './chef-layout.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ChefLayout {
// inject() : demande au conteneur une instance du service
session = inject(SessionService);
private router = inject(Router);
sortir(): void {
this.session.sortir();
// navigateByUrl : navigation déclenchée depuis la classe
this.router.navigateByUrl('/entrer');
}
}<h1>Espace chef</h1>
<!-- {{ }} : affiche la valeur de la classe -->
<p>Connecté : {{ session.nom() }}</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="sortir()">Sortir</button>
<nav>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/chef">Tableau de bord</a>
<a routerLink="/chef/recettes">Gérer les recettes</a>
</nav>
<!-- router-outlet : emplacement où s’affiche la page de la route active -->
<router-outlet />session est public (pas de private) parce que le template le lit. Après sortir(), le signal nom redevient null et connecte() passe à false : le guard refuse alors l'accès à /chef, puisqu'il est rejoué à chaque navigation.
Étape 15 L'espace bibliothécaire protégé
Ce qui est réalisé : Dans cette étape, nous le réservons aux bibliothécaires.
Fichiers concernés : src/app/auth/auth.service.ts · src/app/login/login.ts · src/app/login/login.html · src/app/auth/auth.guard.ts · src/app/app.routes.ts · src/app/app.ts · src/app/app.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 18
Que fait un guard
canActivatequi renvoietrue?Renvoyer true laisse passer la navigation.
Pourquoi renvoie-t-on
router.createUrlTree(['/login'])plutôt que de naviguer dans le guard ?Renvoyer une UrlTree est la façon recommandée de rediriger depuis un guard.
Quelle est la différence entre
canMatchetcanActivate?canMatch est évalué en amont : la route refusée est traitée comme si elle n'existait pas.
Un guard suffit-il à sécuriser les données d'une application ?
Le guard protège l'interface ; l'API doit aussi refuser les accès non autorisés.
Quelle est la forme moderne d'un guard ?
Les guards fonctionnels sont la forme recommandée depuis Angular récent.
Code de Médiathèque à la fin du chapitre (7 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, computed, signal } from '@angular/core';
export type Role = 'LECTEUR' | 'BIBLIOTHECAIRE';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AuthService {
readonly role = signal<Role | null>(null);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly loggedIn = computed(() => this.role() !== null);
readonly isLibrarian = computed(() => this.role() === 'BIBLIOTHECAIRE');
login(role: Role): void {
this.role.set(role); // simulation d'une connexion
}
logout(): void {
this.role.set(null);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService, Role } from '../auth/auth.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-login',
templateUrl: './login.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Login {
// inject() : demande au conteneur une instance du service
private auth = inject(AuthService);
private router = inject(Router);
signIn(role: Role): void {
this.auth.login(role);
// navigateByUrl : navigation déclenchée depuis la classe
this.router.navigateByUrl(role === 'BIBLIOTHECAIRE' ? '/admin' : '/catalogue');
}
}<h1>Connexion</h1>
<p class="muted">Connexion simulée : le vrai contrôle sera fait par le serveur Spring.</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" (click)="signIn('LECTEUR')">Entrer comme lecteur</button>
<button class="btn btn-secondary" (click)="signIn('BIBLIOTHECAIRE')">Entrer comme bibliothécaire</button>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const librarianGuard: CanMatchFn = () => {
// inject() : demande au conteneur une instance du service
const auth = inject(AuthService);
// createUrlTree : construit une redirection vers une autre route
return auth.isLibrarian() ? true : inject(Router).createUrlTree(['/login']);
};import { Routes } from '@angular/router';
import { Home } from './home/home';
import { Catalogue } from './catalogue/catalogue';
import { MediaDetail } from './media-detail/media-detail';
import { Loans } from './loans/loans';
import { Contact } from './contact/contact';
import { AdminLayout } from './admin/admin-layout/admin-layout';
import { Dashboard } from './admin/dashboard/dashboard';
import { AdminMedias } from './admin/admin-medias/admin-medias';
import { Login } from './login/login';
import { librarianGuard } from './auth/auth.guard';
import { NotFound } from './not-found/not-found';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'catalogue', component: Catalogue },
{ path: 'catalogue/:id', component: MediaDetail },
{ path: 'emprunts', component: Loans },
{ path: 'contact', component: Contact },
{ path: 'login', component: Login },
{
path: 'admin',
canMatch: [librarianGuard],
component: AdminLayout,
children: [
{ path: '', component: Dashboard },
{ path: 'medias', component: AdminMedias },
],
},
{ path: '**', component: NotFound },
];// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service';
import { LibraryClock } from './library-clock/library-clock';
import { SelectionBadge } from './selection-badge/selection-badge';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive, SelectionBadge, LibraryClock],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
// inject() : demande au conteneur une instance du service
protected auth = inject(AuthService);
}<header class="site-header">
<div class="container site-header__inner">
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="logo" routerLink="/">Médiathèque</a>
<nav class="site-nav">
<a routerLink="/" routerLinkActive="actif" [routerLinkActiveOptions]="{ exact: true }">Accueil</a>
<a routerLink="/catalogue" routerLinkActive="actif">Catalogue</a>
<a routerLink="/emprunts" routerLinkActive="actif">Mes emprunts</a>
<a routerLink="/contact" routerLinkActive="actif">Contact</a>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (auth.loggedIn()) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn btn-secondary" (click)="auth.logout()">Se déconnecter</button>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<a routerLink="/login">Connexion</a>
}
<app-selection-badge />
<app-library-clock />
</nav>
</div>
</header>
<main class="container">
<!-- router-outlet : emplacement où s’affiche la page de la route active -->
<router-outlet />
</main>
<footer class="site-footer">
<div class="container">Médiathèque · Formation Angular</div>
</footer>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 6 · 6 / 19
Formulaires
Chapitre 19 · Formulaires
Les formulaires réactifs : les bases
Construire un formulaire dans la classe TypeScript avec FormControl, FormGroup et FormBuilder, puis le lier au template.
Glossaire du chapitre
- FormControl
- Représente un champ de formulaire et sa valeur.
- FormGroup
- Regroupe plusieurs contrôles dans un même formulaire.
- FormBuilder
- Outil qui construit les formulaires avec un vocabulaire plus court.
- nonNullable
- Option qui garantit que la valeur d’un champ n’est jamais
null. - getRawValue()
- Renvoie toutes les valeurs du formulaire, y compris les champs désactivés.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous construisons un formulaire de recette (titre, durée, végétarienne) avec les formulaires réactifs. Créez un composant recette-form et affichez-le dans App.
Au programme : 19.1 Pourquoi les formulaires réactifs ? · 19.2 FormControl et FormGroup · 19.3 Lier le formulaire au template · 19.4 FormBuilder : une écriture plus courte · 19.5 Et le template-driven ? Une comparaison
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 19.4.A Un formulaire d'inscription à un atelierEssentiel
Dans demo, créez un composant atelier avec un formulaire réactif construit avec FormBuilder :
- trois champs :
prenom(texte),niveau(liste déroulante<select>: débutant, confirmé) etnewsletter(case à cocher) ; - un aperçu en direct sous le formulaire :
Bonjour {{ form.controls.prenom.value }}; - au clic sur S'inscrire, affichez les valeurs dans la console avec
getRawValue(), puis remettez le formulaire à zéro avecform.reset().
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-atelier',
imports: [ReactiveFormsModule],
templateUrl: './atelier.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Atelier {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
prenom: [''],
niveau: ['debutant'],
newsletter: [false],
});
inscrire(): void {
console.log(this.form.getRawValue());
this.form.reset();
}
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form" (ngSubmit)="inscrire()">
<input formControlName="prenom" placeholder="Prénom">
<select formControlName="niveau">
<option value="debutant">Débutant</option>
<option value="confirme">Confirmé</option>
</select>
<label><input formControlName="newsletter" type="checkbox"> Newsletter</label>
<button type="submit">S'inscrire</button>
</form>
<!-- {{ }} : affiche la valeur de la classe -->
<p>Bonjour {{ form.controls.prenom.value }}</p>Avec nonNullable, form.reset() remet chaque champ à sa valeur initiale ('', 'debutant', false) et jamais à null.
Étape 16 Suggérer un média
Ce qui est réalisé : Dans cette étape, nous la transformons en formulaire de suggestion : un lecteur indique
Fichiers concernés : src/app/contact/contact.ts · src/app/contact/contact.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 19
Que représente un FormControl ?
Un FormControl gère la valeur d'un champ unique.
Pourquoi utiliser
nonNullable: true?Avec nonNullable, la réinitialisation remet une chaîne vide au lieu de null.
Quelle directive lie un champ à son FormControl dans le template ?
formControlName relie le champ à la clé du FormGroup parent.
Quel module doit être importé pour les formulaires réactifs ?
ReactiveFormsModule fournit les directives formGroup et formControlName.
Quel est l'avantage de FormBuilder ?
FormBuilder est un raccourci pour construire les FormGroup et FormControl.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-contact',
imports: [ReactiveFormsModule],
templateUrl: './contact.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Contact {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
name: [''],
email: [''],
message: [''],
});
submit(): void {
console.log(this.form.getRawValue());
}
}<h1>Suggérer un média</h1>
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="name">Nom</label>
<input id="name" formControlName="name">
</div>
<div class="field">
<label for="email">Adresse e-mail</label>
<input id="email" type="email" formControlName="email">
</div>
<div class="field">
<label for="message">Le média que vous aimeriez voir</label>
<textarea id="message" formControlName="message"></textarea>
</div>
<button class="btn" type="submit">Envoyer</button>
</form>
<!-- {{ }} : affiche la valeur de la classe -->
<p class="muted">Bonjour {{ form.controls.name.value }}</p>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 20 · Formulaires
Validation et messages d'erreurs
Appliquer des règles de validation intégrées ou personnalisées, afficher les erreurs au bon moment, et valider plusieurs champs ensemble.
Glossaire du chapitre
- Validateur
- Fonction qui vérifie une valeur et renvoie une erreur si elle ne convient pas.
- Validators.required
- Validateur intégré : le champ ne doit pas être vide.
- touched
- Indique que l’utilisateur est passé dans le champ. Utile pour n’afficher l’erreur qu’au bon moment.
- Validateur personnalisé
- Votre propre règle de validation, écrite comme une fonction.
- Erreur de groupe
- Erreur qui concerne plusieurs champs, par exemple deux mots de passe différents.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous validons un formulaire d'avis sur une recette (pseudo, note, commentaire), puis un formulaire de séjour (dates d'arrivée et de départ). Créez deux composants avis et sejour (ng generate component …) et affichez-les dans App.
Au programme : 20.1 Les validateurs intégrés · 20.2 Afficher les erreurs au bon moment · 20.3 Un validateur personnalisé · 20.4 Validation croisée : deux champs ensemble
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 20.4.A Un formulaire de réservation valideEssentiel
Dans demo, créez un composant reservation-table avec un formulaire réactif :
- un nom obligatoire de 3 à 20 caractères ;
- un nombre de personnes entre 1 et 10 ;
- une heure d'arrivée (
type="time") obligatoire ; - des messages d'erreur affichés seulement après que le champ a été touché ;
- le bouton Réserver désactivé tant que le formulaire est invalide.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-reservation-table',
imports: [ReactiveFormsModule],
templateUrl: './reservation-table.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ReservationTable {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
nom: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(20)]],
personnes: [2, [Validators.required, Validators.min(1), Validators.max(10)]],
heure: ['', Validators.required],
});
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form">
<input formControlName="nom" placeholder="Nom">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.nom.touched && form.controls.nom.invalid) {
<small>Le nom doit compter de 3 à 20 caractères.</small>
}
<input formControlName="personnes" type="number">
@if (form.controls.personnes.touched && form.controls.personnes.invalid) {
<small>Entre 1 et 10 personnes.</small>
}
<input formControlName="heure" type="time">
<button [disabled]="form.invalid">Réserver</button>
</form>Chaque message n'apparaît qu'après que le champ a été touché : l'utilisateur n'est pas accueilli par des erreurs avant d'avoir commencé à saisir.
Étape 17 Le formulaire de connexion
Ce qui est réalisé : Dans cette étape, nous remplaçons les boutons par un formulaire réactif avec validation, en réutilisant FormBuilder (chapitre précédent) et les validateurs intégrés (section 20.1).
Fichiers concernés : src/app/auth/auth.service.ts · src/app/login/login.ts · src/app/login/login.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 3 questions✓
QCM · Chapitre 20
Quelle directive relie un champ HTML à un contrôle du formulaire réactif ?
formControlName reçoit le nom de la clé du groupe.
Pourquoi afficher l'erreur seulement quand le champ est
touched?touched passe à true quand la personne quitte le champ.
Que fait
if (this.form.invalid) return;au début de onSubmit ?La méthode s'arrête immédiatement si le formulaire est invalide.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 20
Que renvoie un validateur quand la valeur est correcte ?
Un validateur renvoie null si tout va bien, et un objet d'erreur sinon.
Pourquoi attend-on que le champ soit
touchedavant d'afficher une erreur ?Afficher l'erreur trop tôt est une mauvaise expérience utilisateur.
Comment vérifier une erreur précise sur un champ ?
hasError() vérifie une erreur identifiée par son nom.
Où place-t-on un validateur qui compare deux champs ?
La validation croisée se déclare au niveau du groupe.
Que fait
form.markAllAsTouched()?Utile après un essai d'envoi quand des erreurs restent cachées.
Code de Médiathèque à la fin du chapitre (3 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, computed, signal } from '@angular/core';
export type Role = 'LECTEUR' | 'BIBLIOTHECAIRE';
const ACCOUNTS: Record<string, Role> = {
'lecteur@example.com': 'LECTEUR',
'biblio@example.com': 'BIBLIOTHECAIRE',
};
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AuthService {
readonly role = signal<Role | null>(null);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly loggedIn = computed(() => this.role() !== null);
readonly isLibrarian = computed(() => this.role() === 'BIBLIOTHECAIRE');
login(role: Role): void {
this.role.set(role); // simulation d'une connexion
}
loginWith(email: string): boolean {
const role = ACCOUNTS[email];
if (!role) return false;
this.role.set(role);
return true;
}
logout(): void {
this.role.set(null);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Login {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
error = false;
onSubmit(): void {
if (this.form.invalid) return;
if (!this.auth.loginWith(this.form.controls.email.value)) {
this.error = true;
return;
}
// navigateByUrl : navigation déclenchée depuis la classe
this.router.navigateByUrl(this.auth.isLibrarian() ? '/admin' : '/catalogue');
}
}<h1>Connexion</h1>
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="field">
<label for="email">Adresse e-mail</label>
<input id="email" type="email" formControlName="email">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.email.touched && form.controls.email.invalid) {
<p class="error">Adresse e-mail invalide.</p>
}
</div>
<div class="field">
<label for="password">Mot de passe</label>
<input id="password" type="password" formControlName="password">
@if (form.controls.password.touched && form.controls.password.invalid) {
<p class="error">8 caractères minimum.</p>
}
</div>
@if (error) {
<p class="error">Compte inconnu.</p>
}
<button class="btn" type="submit" [disabled]="form.invalid">Se connecter</button>
</form>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 21 · Formulaires
Soumission et traitement des données
Traiter l'envoi d'un formulaire proprement : vérifier la validité, récupérer les valeurs, réinitialiser, et transmettre les données à un service.
Glossaire du chapitre
- submit
- Action d’envoi du formulaire, déclenchée par le bouton ou par l’événement
ngSubmit. - markAllAsTouched()
- Marque tous les champs comme touchés, pour afficher toutes les erreurs d’un coup.
- reset()
- Vide le formulaire et remet ses états à zéro.
- Omit
- Type TypeScript qui retire une propriété d’un type, par exemple
idavant un ajout. - POST
- Verbe HTTP qui envoie des données pour créer une ressource.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous gardons le formulaire d'avis du chapitre 20 (avis) et nous lui faisons enregistrer ses avis dans un service AvisService. Cet exemple reste dans le carnet de recettes.
Au programme : 21.1 Gérer l'envoi · 21.2 getRawValue : toutes les valeurs · 21.3 Réinitialiser le formulaire · 21.4 Transmettre les données à un service
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 21.4.A La liste des avisBonus
Dans demo :
- Ajoutez le service
AvisServiceci-dessus et faites-le utiliser par le formulaireavis(méthodesubmit()). - Créez un composant
liste-avisqui injecte le service et affiche la liste des avis (pseudo,note/ 5) avec un@for, ou « Aucun avis » avec@empty. - Affichez
<app-avis />et<app-liste-avis />côte à côte dansApp: un avis publié doit apparaître tout de suite dans la liste.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { AvisService } from '../avis.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-liste-avis',
templateUrl: './liste-avis.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ListeAvis {
// inject() : demande au conteneur une instance du service
protected avisService = inject(AvisService);
}<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (a of avisService.liste(); track a.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ a.pseudo }} : {{ a.note }} / 5</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun avis</li>
}
</ul>Le formulaire et la liste ne se connaissent pas : ils partagent le service (chapitre 15). La liste lit un signal en lecture seule, elle se met donc à jour dès qu'un avis est ajouté.
Étape 19 Ajouter un média
Ce qui est réalisé : Dans cette étape :
- le bibliothécaire ajoute un média depuis son espace ;
- un formulaire valide les champs, puis confie l'ajout au
MediaService; - le nouveau média apparaît aussitôt dans le catalogue, sans rechargement.
Fichiers concernés : src/app/media.service.ts · src/app/admin/media-form/media-form.ts · src/app/admin/media-form/media-form.html · src/app/admin/admin-medias/admin-medias.ts · src/app/admin/admin-medias/admin-medias.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 21
Pourquoi vérifier
form.invalidau début desubmit()?C'est la barrière de validation avant tout traitement.
Quelle différence entre
form.valueetform.getRawValue()?Un champ désactivé est absent de value mais présent dans getRawValue.
Que fait
form.reset()?reset() restaure l'état initial du formulaire.
Où doit se trouver la logique d'enregistrement des données ?
Le service porte la logique, le composant se contente de déclencher l'action.
Pourquoi le template ne peut-il pas lire un service
private?Le modificateur private limite l'accès à la classe elle-même.
Code de Médiathèque à la fin du chapitre (5 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, signal } from '@angular/core';
import { MEDIAS } from './data/medias';
import { Media } from './models/media';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
private medias = signal<Media[]>(MEDIAS);
getAll(): Media[] {
return this.medias();
}
getById(id: number): Media | undefined {
return this.medias().find((m) => m.id === id);
}
search(query: string): Media[] {
const q = query.trim().toLowerCase();
return this.medias().filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
}
add(media: Omit<Media, 'id'>): void {
const id = Math.max(0, ...this.medias().map((m) => m.id)) + 1;
this.medias.update((list) => [...list, { id, ...media }]);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MediaService } from '../../media.service';
import { MediaType } from '../../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-form',
imports: [ReactiveFormsModule],
templateUrl: './media-form.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaForm {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
private mediaService = inject(MediaService);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
title: ['', Validators.required],
author: ['', Validators.required],
year: [2000, [Validators.required, Validators.min(1000), Validators.max(2100)]],
type: ['livre' as MediaType, Validators.required],
genre: ['', Validators.required],
loanDays: [14, [Validators.required, Validators.min(1)]],
available: [true],
});
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.mediaService.add(this.form.getRawValue());
this.form.reset();
}
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="title">Titre</label>
<input id="title" formControlName="title">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.title.touched && form.controls.title.invalid) {
<p class="error">Le titre est obligatoire.</p>
}
</div>
<div class="field">
<label for="author">Auteur ou réalisateur</label>
<input id="author" formControlName="author">
@if (form.controls.author.touched && form.controls.author.invalid) {
<p class="error">L'auteur est obligatoire.</p>
}
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" formControlName="type">
<option value="livre">Livre</option>
<option value="film">Film</option>
<option value="jeu">Jeu</option>
</select>
</div>
<div class="field">
<label for="genre">Genre</label>
<input id="genre" formControlName="genre">
@if (form.controls.genre.touched && form.controls.genre.invalid) {
<p class="error">Le genre est obligatoire.</p>
}
</div>
<div class="field">
<label for="year">Année</label>
<input id="year" type="number" formControlName="year">
@if (form.controls.year.touched && form.controls.year.invalid) {
<p class="error">L'année doit être comprise entre 1000 et 2100.</p>
}
</div>
<div class="field">
<label for="loanDays">Durée de prêt (jours)</label>
<input id="loanDays" type="number" formControlName="loanDays">
@if (form.controls.loanDays.touched && form.controls.loanDays.invalid) {
<p class="error">La durée de prêt est d'au moins 1 jour.</p>
}
</div>
<label><input type="checkbox" formControlName="available"> Disponible</label>
<p><button class="btn" type="submit">Ajouter</button></p>
</form>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { MediaService } from '../../media.service';
import { MediaForm } from '../media-form/media-form';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-admin-medias',
imports: [MediaForm],
templateUrl: './admin-medias.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AdminMedias {
// inject() : demande au conteneur une instance du service
protected mediaService = inject(MediaService);
}<h2>Gérer les médias</h2>
<app-media-form />
<table class="table">
<thead><tr><th>Titre</th><th>Auteur</th><th>Type</th><th>Disponible</th></tr></thead>
<tbody>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of mediaService.getAll(); track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<tr><td>{{ m.title }}</td><td>{{ m.author }}</td><td>{{ m.type }}</td><td>{{ m.available ? 'Oui' : 'Non' }}</td></tr>
}
</tbody>
</table>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 7 · 7 / 19
Données et API
Chapitre 22 · Données et API
HttpClient : appeler une API REST
Appeler une API REST avec GET, POST, PUT, PATCH et DELETE, à partir d'un service, et utiliser un faux serveur local pour travailler sans backend.
Glossaire du chapitre
- HttpClient
- Service d’Angular qui envoie des requêtes HTTP vers une API.
- API REST
- Interface qui expose des données par URL, avec les verbes GET, POST, PUT, PATCH et DELETE.
- JSON
- Format texte des données échangées avec l’API : c’est l’équivalent de
json_encode. - json-server
- Petit serveur qui simule une API REST à partir d’un fichier
db.json. - Observable
- Flux de valeurs dans le temps. Une requête HTTP renvoie un Observable.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous appelons une API publique de test, JSONPlaceholder (https://jsonplaceholder.typicode.com) :
- elle fournit des « articles » (
posts) sans aucune installation ; - elle accepte les requêtes de lecture, et simule les créations ;
- les exemples de ce chapitre vivent donc dans
demo, séparés de Médiathèque.
Au programme : 22.1 HttpClient, c'est quoi ? · 22.2 Activer HttpClient · 22.3 GET : lire des données · 22.4 POST : créer une ressource · 22.5 PUT, PATCH et DELETE · 22.6 Observer les requêtes dans le navigateur
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 22.5.A Lire un seul articleEssentiel
Dans demo :
- Ajoutez
provideHttpClient()si ce n'est pas déjà fait. - Dans
Posts, ajoutez un signalpost = signal<Post | null>(null)et chargez l'article numéro 1 avecfetchOne(1). - Affichez son titre et son texte dans un
@if (post(); as p).
Voir la solution
post = signal<Post | null>(null);
constructor() {
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.postService.fetchOne(1).subscribe((p) => this.post.set(p));
}<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (post(); as p) {
<!-- {{ }} : affiche la valeur de la classe -->
<h3>{{ p.title }}</h3>
<p>{{ p.body }}</p>
}Tant que la réponse n'est pas arrivée, le signal vaut null et rien ne s'affiche.
Étape 20 Médiathèque lit son API
Ce qui est réalisé : Dans cette étape :
- Médiathèque les lit sur une API : un faux serveur local,
json-server, qui transforme un fichier JSON en API REST complète ; - plus tard, il sera remplacé par votre API Spring Boot ;
- seule l'adresse changera.
Fichiers concernés : db.json · src/app/app.config.ts · src/app/media.service.ts · src/app/app.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 22
Quel service Angular sert à appeler une API REST ?
HttpClient est le service officiel, fourni avec provideHttpClient().
Quelle méthode HTTP correspond à la suppression d'une ressource ?
DELETE supprime la ressource désignée par son URL.
Que se passe-t-il si on écrit
this.http.delete(url);sans s'abonner ?Un Observable est paresseux : il ne fait rien tant qu'on ne s'y abonne pas.
Quelle différence entre PUT et PATCH ?
PATCH est une mise à jour partielle.
Que fait le générique dans
this.http.get<Media[]>(url)?Le générique décrit la réponse, il n'est pas vérifié à l'exécution.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
{
"medias": [
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"year": 1965,
"type": "livre",
"genre": "Science-fiction",
"available": true,
"rating": 4.7,
"loanDays": 21
},
{
"id": 2,
"title": "Le Petit Prince",
"author": "Antoine de Saint-Exupéry",
"year": 1943,
"type": "livre",
"genre": "Conte",
"available": true,
"rating": 4.8,
"loanDays": 21
},
{
"id": 3,
"title": "L'Étranger",
"author": "Albert Camus",
"year": 1942,
"type": "livre",
"genre": "Roman",
"available": false,
"rating": 4.2,
"loanDays": 21
},
{
"id": 4,
"title": "Fondation",
"author": "Isaac Asimov",
"year": 1951,
"type": "livre",
"genre": "Science-fiction",
"available": true,
"loanDays": 21
},
{
"id": 5,
"title": "Blade Runner",
"author": "Ridley Scott",
"year": 1982,
"type": "film",
"genre": "Science-fiction",
"available": true,
"rating": 4.5,
"loanDays": 7
},
{
"id": 6,
"title": "Le Voyage de Chihiro",
"author": "Hayao Miyazaki",
"year": 2001,
"type": "film",
"genre": "Animation",
"available": true,
"rating": 4.9,
"loanDays": 7
},
{
"id": 7,
"title": "Les Sept Samouraïs",
"author": "Akira Kurosawa",
"year": 1954,
"type": "film",
"genre": "Aventure",
"available": false,
"rating": 4.8,
"loanDays": 7
},
{
"id": 8,
"title": "Le Fabuleux Destin d'Amélie Poulain",
"author": "Jean-Pierre Jeunet",
"year": 2001,
"type": "film",
"genre": "Comédie",
"available": true,
"rating": 4.3,
"loanDays": 7
},
{
"id": 9,
"title": "Hollow Knight",
"author": "Team Cherry",
"year": 2017,
"type": "jeu",
"genre": "Aventure",
"available": true,
"rating": 4.6,
"loanDays": 14
},
{
"id": 10,
"title": "Celeste",
"author": "Maddy Makes Games",
"year": 2018,
"type": "jeu",
"genre": "Plateforme",
"available": true,
"rating": 4.4,
"loanDays": 14
},
{
"id": 11,
"title": "Portal 2",
"author": "Valve",
"year": 2011,
"type": "jeu",
"genre": "Puzzle",
"available": false,
"rating": 4.7,
"loanDays": 14
},
{
"id": 12,
"title": "Stardew Valley",
"author": "ConcernedApe",
"year": 2016,
"type": "jeu",
"genre": "Simulation",
"available": true,
"loanDays": 14
}
],
"genres": [
{
"id": 1,
"name": "Animation"
},
{
"id": 2,
"name": "Aventure"
},
{
"id": 3,
"name": "Comédie"
},
{
"id": 4,
"name": "Conte"
},
{
"id": 5,
"name": "Plateforme"
},
{
"id": 6,
"name": "Puzzle"
},
{
"id": 7,
"name": "Roman"
},
{
"id": 8,
"name": "Science-fiction"
},
{
"id": 9,
"name": "Simulation"
}
],
"users": [
{
"id": 1,
"email": "lecteur@example.com",
"role": "lecteur"
},
{
"id": 2,
"email": "biblio@example.com",
"role": "bibliothecaire"
}
],
"loans": []
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// provideRouter : active le routeur avec la liste des routes
provideRouter(routes, withComponentInputBinding()),
// provideHttpClient : active HttpClient pour appeler des API
provideHttpClient(),
],
};import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, inject, signal } from '@angular/core';
import { Observable } from 'rxjs';
import { Media } from './models/media';
const API = 'http://localhost:3000';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
private medias = signal<Media[]>([]);
fetchAll(): Observable<Media[]> {
// Requête HTTP : renvoie un Observable, qui ne part qu’à l’abonnement
return this.http.get<Media[]>(`${API}/medias`);
}
fetchOne(id: number): Observable<Media> {
return this.http.get<Media>(`${API}/medias/${id}`);
}
load(): void {
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.fetchAll().subscribe((list) => this.medias.set(list));
}
getAll(): Media[] {
return this.medias();
}
getById(id: number): Media | undefined {
return this.medias().find((m) => m.id === id);
}
search(query: string): Media[] {
const q = query.trim().toLowerCase();
return this.medias().filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
}
add(media: Omit<Media, 'id'>): void {
this.http
.post<Media>(`${API}/medias`, media)
.subscribe((created) => this.medias.update((list) => [...list, created]));
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { AuthService } from './auth/auth.service';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { LibraryClock } from './library-clock/library-clock';
import { MediaService } from './media.service';
import { SelectionBadge } from './selection-badge/selection-badge';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive, SelectionBadge, LibraryClock],
templateUrl: './app.html',
styleUrl: './app.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class App {
// inject() : demande au conteneur une instance du service
protected auth = inject(AuthService);
constructor() {
inject(MediaService).load();
}
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 23 · Données et API
Introduction à RxJS
Comprendre les Observables, les abonnements, les opérateurs de transformation, et l'usage du pipe async dans les templates.
Glossaire du chapitre
- Observable
- Flux de valeurs qui arrivent dans le temps. Rien ne part tant qu’on ne s’y abonne pas.
- subscribe
- S’abonner à un Observable pour recevoir ses valeurs.
- pipe
- Enchaîne des opérateurs qui transforment le flux.
- Opérateur
- Fonction RxJS qui transforme un flux, par exemple
map,filteroutap. - async pipe
- Pipe du template qui s’abonne et se désabonne tout seul.
Comprendre et essayer (projet demo)
Ce qui sera montré : RxJS en bref. RxJS est une bibliothèque, déjà utilisée par Angular, qui représente des données qui arrivent au fil du temps (réponse HTTP, saisie au clavier, minuteur) sous forme d'un flux appelé Observable. On s'abonne à ce flux pour recevoir chaque valeur, et on peut la transformer au passage avec des opérateurs (map, filter…), un peu comme on enchaîne des transformations sur un tableau, mais pour des données qui n'existent pas encore. En PHP, l'équivalent le plus proche serait un générateur (yield) que l'on consommerait au fur et à mesure.
Au programme : 23.1 Observable ou Promise ? · 23.2 subscribe et désabonnement · 23.3 Transformer avec pipe, map, filter et tap · 23.4 Le pipe async dans le template
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 23.4.A Les articles courtsEssentiel
Dans demo, créez un composant short-posts qui affiche, avec le pipe async, les articles dont le titre fait moins de 30 caractères (utilisez map et Array.filter). Si aucun article ne convient, affichez « Aucun article court ».
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { map } from 'rxjs';
import { PostService } from '../post.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-short-posts',
imports: [AsyncPipe],
templateUrl: './short-posts.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class ShortPosts {
// inject() : demande au conteneur une instance du service
private postService = inject(PostService);
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
short$ = this.postService.fetchAll().pipe(
map((list) => list.filter((p) => p.title.length < 30)),
);
}<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (short$ | async; as posts) {
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (p of posts; track p.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ p.title }}</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun article court</li>
}
</ul>
}Étape 21 « Disponibles maintenant »
Ce qui est réalisé : Sur la page d'accueil, nous ajoutons un bloc Disponibles maintenant. Il liste les médias libres à l'emprunt en lisant directement un Observable avec le pipe async, sans subscribe dans la classe.
Fichiers concernés : src/app/available-now/available-now.ts · src/app/available-now/available-now.html · src/app/home/home.ts · src/app/home/home.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 23
Quelle est la différence principale entre une Promise et un Observable ?
Un Observable est un flux, une Promise est une valeur unique.
Que se passe-t-il si on ne s'abonne jamais à un Observable de HttpClient ?
Un Observable est paresseux.
Que fait l'opérateur
mapdans un pipe ?map applique une transformation à chaque valeur, comme array_map en PHP.
Que fait le pipe
async?Le pipe async gère l'abonnement pour vous.
Quelle convention de nommage indique un Observable ?
Le suffixe $ est une convention largement utilisée.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { map } from 'rxjs';
import { MediaService } from '../media.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-available-now',
imports: [AsyncPipe],
templateUrl: './available-now.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AvailableNow {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
available$ = this.mediaService.fetchAll().pipe(
map((list) => list.filter((m) => m.available)),
);
}<h2>Disponibles maintenant</h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (available$ | async; as medias) {
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of medias; track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ m.title }} ({{ m.author }})</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun média disponible.</li>
}
</ul>
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AvailableNow } from '../available-now/available-now';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-home',
imports: [RouterLink, AvailableNow],
templateUrl: './home.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Home {}<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/catalogue">Voir le catalogue</a>
<a routerLink="/catalogue" [queryParams]="{ q: 'dune' }">Chercher Dune</a>
</section>
<app-available-now />Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 24 · Données et API
Flux asynchrones : switchMap, debounce, combinaisons
Gérer les recherches en direct, éviter les réponses dans le désordre, combiner plusieurs flux, et nettoyer les abonnements.
Glossaire du chapitre
- switchMap
- Remplace le flux précédent par le nouveau : une requête plus récente annule l’ancienne.
- debounceTime
- Attend une pause dans les événements avant de transmettre la valeur.
- distinctUntilChanged
- Ignore une valeur identique à la précédente.
- combineLatest / forkJoin
- Combinent plusieurs flux :
combineLatestréagit à chaque changement,forkJoinattend la fin de tous. - takeUntilDestroyed
- Arrête un abonnement automatiquement à la destruction du composant.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous construisons une recherche en direct sur les articles de JSONPlaceholder (GET /posts?q=…, du chapitre 22).
Au programme : 24.1 switchMap : une seule requête à la fois · 24.2 debounceTime et distinctUntilChanged · 24.3 combineLatest et forkJoin · 24.4 takeUntilDestroyed : nettoyer automatiquement
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 24.4.A Une recherche avec un minimum de lettresBonus
Dans post-search, ne lancez la recherche que si le terme fait au moins 2 caractères (ou est vide, pour la liste complète). Indice : l'opérateur filter de RxJS, placé après debounceTime.
Voir la solution
import { filter } from 'rxjs';
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
results$ = this.search.valueChanges.pipe(
startWith(''),
// debounceTime : attend une pause de saisie avant d’agir
debounceTime(300),
filter((term) => term.length === 0 || term.length >= 2),
// distinctUntilChanged : ignore une valeur identique à la précédente
distinctUntilChanged(),
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap((term) => this.postService.search(term)),
);filter laisse passer la valeur si la condition est vraie, sinon il l'ignore : la liste précédente reste affichée jusqu'à ce que le terme soit assez long.
Étape 22 La recherche dynamique
Ce qui est réalisé : Dans cette étape, la frappe interroge l'API :
- le champ attend 300 ms de pause ;
- il ignore les doublons ;
- il n'affiche que la dernière réponse.
Fichiers concernés : src/app/media.service.ts · src/app/media-search/media-search.ts · src/app/media-search/media-search.html · src/styles.css · src/app/catalogue/catalogue.ts · src/app/catalogue/catalogue.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 24
Quel opérateur annule la requête précédente quand une nouvelle valeur arrive ?
switchMap abandonne l'Observable précédent pour suivre le dernier.
À quoi sert
debounceTime(300)?debounce attend une pause dans les événements avant de réagir.
Quelle différence entre combineLatest et forkJoin ?
forkJoin émet une seule fois quand tous les flux sont terminés.
Que fait
takeUntilDestroyed()?C'est un nettoyage automatique des abonnements.
Pourquoi utiliser
distinctUntilChangeddans une recherche ?Il évite de relancer la même recherche.
Code de Médiathèque à la fin du chapitre (6 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, inject, signal } from '@angular/core';
import { Observable } from 'rxjs';
import { Media } from './models/media';
const API = 'http://localhost:3000';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
private medias = signal<Media[]>([]);
fetchAll(): Observable<Media[]> {
// Requête HTTP : renvoie un Observable, qui ne part qu’à l’abonnement
return this.http.get<Media[]>(`${API}/medias`);
}
fetchOne(id: number): Observable<Media> {
return this.http.get<Media>(`${API}/medias/${id}`);
}
load(): void {
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.fetchAll().subscribe((list) => this.medias.set(list));
}
getAll(): Media[] {
return this.medias();
}
getById(id: number): Media | undefined {
return this.medias().find((m) => m.id === id);
}
search(query: string): Media[] {
const q = query.trim().toLowerCase();
return this.medias().filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
}
searchApi(term: string): Observable<Media[]> {
return this.http.get<Media[]>(`${API}/medias`, { params: { q: term } });
}
add(media: Omit<Media, 'id'>): void {
this.http
.post<Media>(`${API}/medias`, media)
.subscribe((created) => this.medias.update((list) => [...list, created]));
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, effect, inject, input } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, startWith, switchMap } from 'rxjs';
import { MediaService } from '../media.service';
import { MediaCard } from '../media-card/media-card';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-search',
imports: [AsyncPipe, ReactiveFormsModule, MediaCard],
templateUrl: './media-search.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaSearch {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
q = input<string>(); // terme de départ (?q=... du catalogue)
search = new FormControl('', { nonNullable: true });
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
results$ = this.search.valueChanges.pipe(
startWith(''),
// debounceTime : attend une pause de saisie avant d’agir
debounceTime(300),
// distinctUntilChanged : ignore une valeur identique à la précédente
distinctUntilChanged(),
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap((term) => this.mediaService.searchApi(term)),
);
constructor() {
// effect : code relancé à chaque changement des signaux qu’il lit
effect(() => this.search.setValue(this.q() ?? '')); // le champ repart de q
}
}<div class="search">
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<input [formControl]="search" placeholder="Rechercher un titre, un auteur...">
</div>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (results$ | async; as list) {
@if (list.length === 0) {
<p>Aucun résultat.</p>
}
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of list; track m.id) {
<app-media-card [media]="m" />
}
</section>
}/* barre de recherche du catalogue (composant media-search) */
.search { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search input { flex: 1; min-width: 0; }// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input } from '@angular/core';
import { MediaSearch } from '../media-search/media-search';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-catalogue',
imports: [MediaSearch],
templateUrl: './catalogue.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Catalogue {
q = input<string>(); // reçoit ?q=... dans l'adresse
}<h1>Catalogue</h1>
<app-media-search [q]="q()" />Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 25 · Données et API
Gérer les erreurs et les états de chargement
Gérer les erreurs réseau proprement avec catchError, et afficher un état de chargement et d'erreur dans l'écran.
Glossaire du chapitre
- catchError
- Opérateur qui réagit à une erreur dans un flux.
- retry
- Opérateur qui relance une requête en cas d’échec.
- throwError
- Crée un flux qui émet une erreur.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous fiabilisons le chargement des articles : valeur de secours, puis états chargement et erreur.
Au programme : 25.1 catchError : rattraper une erreur · 25.2 Gérer l'état : chargement et erreur
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
Étape 23 Fiabiliser les appels à l'API
Ce qui est réalisé : Dans cette étape, nous ajoutons à Médiathèque :
- un état chargement / erreur dans « Disponibles maintenant » : un message clair quand le serveur est arrêté, plutôt qu'un bloc vide.
Fichiers concernés : src/app/available-now/available-now.ts · src/app/available-now/available-now.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 2 questions✓
QCM · Chapitre 25
Que fait
catchError?catchError réagit à l'erreur dans le pipeline.
Pourquoi utiliser des signals pour
loadingeterror?Les signals mettent à jour l'affichage dès que l'état change.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject, signal } from '@angular/core';
import { Media } from '../models/media';
import { MediaService } from '../media.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-available-now',
templateUrl: './available-now.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AvailableNow {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
medias = signal<Media[]>([]);
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
loading = signal(true);
error = signal<string | null>(null);
constructor() {
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.mediaService.fetchAll().subscribe({
next: (list) => {
this.medias.set(list.filter((m) => m.available));
this.loading.set(false);
},
error: () => {
this.error.set('Impossible de charger les médias.');
this.loading.set(false);
},
});
}
}<h2>Disponibles maintenant</h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (loading()) {
<p>Chargement...</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else if (error(); as message) {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="error">{{ message }}</p>
} @else {
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of medias(); track m.id) {
<li>{{ m.title }} ({{ m.author }})</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun média disponible.</li>
}
</ul>
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 26 · Données et API
Les ressources modernes : httpResource et les signals
Utiliser httpResource, stable depuis Angular 22, pour charger des données avec son état de chargement et d'erreur, et passer entre Observables et signals.
Glossaire du chapitre
- httpResource
- Ressource HTTP réactive : Angular gère le chargement, les données et l’erreur.
- toSignal
- Transforme un Observable en signal.
- toObservable
- Transforme un signal en Observable.
- reload()
- Relance la requête d’une ressource, par exemple après un ajout.
- defaultValue
- Valeur affichée tant que les données ne sont pas arrivées.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous réécrivons le composant post-list du chapitre 25 avec httpResource, puis nous testons les ponts entre signals et Observables.
Au programme : 26.1 httpResource : charger une donnée avec un état · 26.2 toSignal : d'un Observable vers un signal · 26.3 toObservable : d'un signal vers un Observable
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 26.3.A Un httpResource qui réagit à un signalBonus
Dans demo, créez un composant post-viewer avec un signal id (valeur 1), deux boutons Précédent / Suivant, et un httpResource qui charge l'article id(). Affichez son titre, ou « Chargement… » pendant la requête.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, signal } from '@angular/core';
// httpResource : appel HTTP réactif, avec chargement et erreur gérés
import { httpResource } from '@angular/common/http';
import { Post } from '../post.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-post-viewer',
templateUrl: './post-viewer.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class PostViewer {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
id = signal(1);
post = httpResource<Post>(
() => `https://jsonplaceholder.typicode.com/posts/${this.id()}`,
);
previous(): void {
this.id.update((n) => Math.max(1, n - 1));
}
next(): void {
this.id.update((n) => n + 1);
}
}<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="previous()">Précédent</button>
<button (click)="next()">Suivant</button>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (post.isLoading()) {
<p>Chargement...</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else if (post.value(); as p) {
<!-- {{ }} : affiche la valeur de la classe -->
<h3>{{ p.title }}</h3>
}Chaque changement de id relance la requête toute seule : l'URL lit le signal.
Étape 24 « Disponibles maintenant » avec httpResource
Ce qui est réalisé : Le bloc « Disponibles maintenant » de l'étape 23 fonctionne, mais il demande beaucoup de code pour gérer à la main le chargement et l'erreur. Nous le réécrivons avec httpResource, et nous ajoutons un bouton Recharger.
Fichiers concernés : src/app/available-now/available-now.ts · src/app/available-now/available-now.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 26
Que donne
httpResourceau template ?httpResource expose value(), isLoading() et error() sous forme de signals.
Que se passe-t-il quand un signal lu dans l'URL d'un httpResource change ?
La fonction d'URL est réactive, comme un computed.
À quoi sert
toSignal?toSignal s'abonne et expose la dernière valeur sous forme de signal.
Pourquoi indiquer un
defaultValuedans httpResource ?Il fournit une valeur de départ sûre pour le template.
Quand utiliser
toObservable?toObservable fait le pont des signals vers RxJS.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed } from '@angular/core';
// httpResource : appel HTTP réactif, avec chargement et erreur gérés
import { httpResource } from '@angular/common/http';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-available-now',
templateUrl: './available-now.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class AvailableNow {
medias = httpResource<Media[]>(() => 'http://localhost:3000/medias', {
defaultValue: [],
});
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
available = computed(() => this.medias.value().filter((m) => m.available));
}<h2>Disponibles maintenant</h2>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" (click)="medias.reload()">Recharger</button>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (medias.isLoading()) {
<p>Chargement...</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else if (medias.error()) {
<p class="error">Impossible de charger les médias.</p>
} @else {
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of available(); track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ m.title }} ({{ m.author }})</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun média disponible.</li>
}
</ul>
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 8 · 8 / 19
Qualité et débogage
Chapitre 27 · Qualité et débogage
Introduction aux tests avec Vitest
Comprendre pourquoi tester, lancer les tests avec ng test, écrire un test simple avec describe, it et expect, et tester une fonction pure.
Glossaire du chapitre
- Test unitaire
- Test qui vérifie une petite partie du code, isolée des autres.
- Vitest
- Outil de test utilisé par défaut par Angular CLI pour les projets actuels.
- describe
- Regroupe des tests qui portent sur le même sujet.
- it
- Un test, avec une phrase qui décrit ce qu’il vérifie.
- expect
- Vérifie qu’une valeur est celle attendue :
expect(total).toBe(10). - Fonction pure
- Fonction qui renvoie toujours le même résultat pour les mêmes valeurs, sans rien modifier autour d’elle.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous testons de petites fonctions du carnet de recettes : afficher une durée de préparation et calculer des quantités pour un nombre de personnes.
Au programme : 27.1 Pourquoi tester ? · 27.2 ng test et Vitest · 27.3 L'anatomie d'un test · 27.4 Tester une fonction pure
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 27.4.A Tester les règles de la recetteEssentiel
Dans recipe-rules.spec.ts, dans le describe('scaleQuantity', ...), ajoutez des tests sur le modèle de celui ci-dessus :
scaleQuantity(200, 2, 4)vaut400;scaleQuantity(300, 3, 1)vaut100(on passe à moins de personnes) ;scaleQuantity(100, 4, 4)vaut100(même nombre de personnes : la quantité ne change pas).
Pour chacun, calculez d'abord le résultat attendu à la main, puis écrivez le test.
Voir la solution
// describe : regroupe les tests d’un même sujet
describe('scaleQuantity', () => {
// it : un test, décrit par sa phrase
it('triple la quantité pour trois fois plus de personnes', () => {
// expect : vérifie que la valeur obtenue est celle attendue
expect(scaleQuantity(200, 2, 6)).toBe(600);
});
it('double pour deux fois plus de personnes', () => {
expect(scaleQuantity(200, 2, 4)).toBe(400);
});
it('divise pour moins de personnes', () => {
expect(scaleQuantity(300, 3, 1)).toBe(100);
});
it('ne change rien pour le même nombre de personnes', () => {
expect(scaleQuantity(100, 4, 4)).toBe(100);
});
});Les calculs à la main : 200 / 2 = 100 par personne, × 4 = 400 ; 300 / 3 = 100 par personne, × 1 = 100 ; 100 / 4 = 25 par personne, × 4 = 100. Chaque it contient une seule idée à vérifier, avec un titre qui la décrit : si un test échoue, son titre indique tout de suite quel cas est cassé.
Si un test échoue, le message indique la valeur attendue et la valeur reçue. Lisez-le avant de modifier le code : c'est souvent le test qui était faux.
Étape 25 Protéger les règles de prêt par des tests
Ce qui est réalisé : Dans cette étape, nous les isolons dans de petites fonctions pures et nous les protégeons par des tests, qui préviendront si une modification future les casse.
Fichiers concernés : src/app/utils/loan-rules.ts · src/app/utils/loan-rules.spec.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 27
Quel est le rôle de describe dans un test ?
describe structure les tests, it décrit chacun d'eux.
Quel matcher faut-il utiliser pour comparer deux tableaux ?
toEqual compare le contenu, toBe compare la référence d'une valeur simple.
Comment s'appelle le fichier de test d'un service nommé media.service.ts ?
Le suffixe .spec.ts est la convention Angular.
Qu'est-ce qu'une fonction pure ?
Une fonction pure est prévisible, donc facile à tester.
Que lance la commande
ng test?ng test exécute les fichiers *.spec.ts.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
import { MAX_LOANS } from '../models/media';
export function formatLoanDuration(days: number): string {
return days === 1 ? '1 jour' : `${days} jours`;
}
export function canBorrowMore(selected: number, max = MAX_LOANS): boolean {
return selected < max;
}
export function returnDate(start: Date, loanDays: number): Date {
const date = new Date(start);
date.setDate(date.getDate() + loanDays);
return date;
}import { describe, it, expect } from 'vitest';
import { canBorrowMore, formatLoanDuration, returnDate } from './loan-rules';
// describe : regroupe les tests d’un même sujet
describe('formatLoanDuration', () => {
// it : un test, décrit par sa phrase
it('met « jour » au singulier pour 1', () => {
// expect : vérifie que la valeur obtenue est celle attendue
expect(formatLoanDuration(1)).toBe('1 jour');
});
it('met « jours » au pluriel au-delà', () => {
expect(formatLoanDuration(21)).toBe('21 jours');
});
});
describe('canBorrowMore', () => {
it('accepte une sélection vide', () => {
expect(canBorrowMore(0)).toBe(true);
});
it("accepte jusqu'à la dernière place, puis refuse", () => {
expect(canBorrowMore(4)).toBe(true);
expect(canBorrowMore(5)).toBe(false);
});
});
describe('returnDate', () => {
it('ajoute la durée de prêt', () => {
expect(returnDate(new Date(2026, 0, 1), 7)).toEqual(new Date(2026, 0, 8));
});
});Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 9 · 9 / 19
Projet guidé
Chapitre 28 · Projet guidé
Projet final : emprunter pour de vrai
Terminer Médiathèque : valider la sélection pour créer de vrais prêts enregistrés par l'API, permettre au bibliothécaire de supprimer un média, et vérifier l'ensemble de l'application.
Glossaire du chapitre
- API_URL
- Constante qui contient l’adresse de l’API. Un seul endroit à changer si elle évolue.
- Modèle
- Interface qui décrit les données manipulées, par exemple
Media. - Route paramétrée
- Route dont l’URL contient un identifiant, par exemple
medias/:id. - Formulaire réactif
- Formulaire décrit dans la classe, avec validation et soumission.
- Service de données
- Service qui appelle l’API et met les données à disposition des pages.
Étape 26 Le service des prêts
Ce qui est réalisé : Un prêt enregistre les médias empruntés, la date et la date de retour. Le serveur lui attribue un identifiant.
Fichiers concernés : src/app/models/loan-record.ts · src/app/core/config.ts · src/app/media.service.ts · src/app/loan.service.ts
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Étape 27 Valider la sélection
Ce qui est réalisé : Le bouton Valider du panneau de sélection doit enchaîner quatre actions : créer le prêt, rendre les médias indisponibles, vider la sélection, et afficher une confirmation. Le calcul de la date de retour réutilise la fonction returnDate testée à l'étape 25.
Fichiers concernés : src/app/media.service.ts · src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html · src/styles.css
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Étape 28 Supprimer un média (zone bibliothécaire)
Ce qui est réalisé : Dans l'espace bibliothécaire, nous ajoutons un bouton Supprimer à chaque ligne du tableau des médias. Le service envoie un DELETE puis retire le média du cache.
Fichiers concernés : src/app/media.service.ts · src/app/admin/admin-medias/admin-medias.html · src/styles.css
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 28
Pourquoi centraliser l'adresse de l'API dans
core/config.ts?Centraliser la configuration évite de la chercher dans tout le projet.
Pourquoi
validate()utilise-t-ilswitchMapentre la création du prêt et les mises à jour ?switchMap enchaîne un second Observable à partir du résultat du premier.
Que fait
forkJoindans cette étape ?forkJoin émet une seule fois, quand tous les flux sont terminés.
Pourquoi le bouton « Supprimer » ne suffit-il pas à protéger les données ?
Un guard Angular améliore l'expérience, mais la sécurité réelle est côté serveur.
Quel rôle joue
tapdanssetAvailable?tap sert aux effets de bord : ici, mettre à jour le signal medias.
Code de Médiathèque à la fin du chapitre (8 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Interface : décrit la forme d’un objet ; elle disparaît à l’exécution
export interface LoanRecord {
id: number;
mediaIds: number[];
date: string; // date du prêt, au format ISO (2026-01-01)
returnDate: string; // date de retour prévue
}export const API_URL = 'http://localhost:3000';import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { API_URL } from './core/config';
import { Media } from './models/media';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
private medias = signal<Media[]>([]);
fetchAll(): Observable<Media[]> {
// Requête HTTP : renvoie un Observable, qui ne part qu’à l’abonnement
return this.http.get<Media[]>(`${API_URL}/medias`);
}
fetchOne(id: number): Observable<Media> {
return this.http.get<Media>(`${API_URL}/medias/${id}`);
}
load(): void {
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.fetchAll().subscribe((list) => this.medias.set(list));
}
getAll(): Media[] {
return this.medias();
}
getById(id: number): Media | undefined {
return this.medias().find((m) => m.id === id);
}
search(query: string): Media[] {
const q = query.trim().toLowerCase();
return this.medias().filter(
(m) => m.title.toLowerCase().includes(q) || m.author.toLowerCase().includes(q),
);
}
searchApi(term: string): Observable<Media[]> {
return this.http.get<Media[]>(`${API_URL}/medias`, { params: { q: term } });
}
setAvailable(id: number, available: boolean): Observable<Media> {
return this.http
.patch<Media>(`${API_URL}/medias/${id}`, { available })
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
.pipe(
tap((updated) =>
this.medias.update((list) => list.map((m) => (m.id === id ? updated : m))),
),
);
}
add(media: Omit<Media, 'id'>): void {
this.http
.post<Media>(`${API_URL}/medias`, media)
.subscribe((created) => this.medias.update((list) => [...list, created]));
}
remove(id: number): void {
this.http
.delete<void>(`${API_URL}/medias/${id}`)
.subscribe(() => this.medias.update((list) => list.filter((m) => m.id !== id)));
}
}import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Service, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { API_URL } from './core/config';
import { LoanRecord } from './models/loan-record';
// Déclare un service, injectable dans les composants
@Service()
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class LoanService {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
create(loan: Omit<LoanRecord, 'id'>): Observable<LoanRecord> {
// Requête HTTP : renvoie un Observable, qui ne part qu’à l’abonnement
return this.http.post<LoanRecord>(`${API_URL}/loans`, loan);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject, signal } from '@angular/core';
import { forkJoin, switchMap } from 'rxjs';
import { LoanService } from '../loan.service';
import { MediaService } from '../media.service';
import { MAX_LOANS } from '../models/media';
import { SelectionService } from '../selection.service';
import { returnDate } from '../utils/loan-rules';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
private loanService = inject(LoanService);
private mediaService = inject(MediaService);
protected max = MAX_LOANS;
confirmation = signal<string | null>(null);
error = signal<string | null>(null);
validate(): void {
const medias = this.selection.list();
if (medias.length === 0) return;
const today = new Date();
const longest = Math.max(...medias.map((m) => m.loanDays));
this.loanService
.create({
mediaIds: medias.map((m) => m.id),
date: today.toISOString().slice(0, 10),
returnDate: returnDate(today, longest).toISOString().slice(0, 10),
})
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
.pipe(
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap(() =>
forkJoin(medias.map((m) => this.mediaService.setAvailable(m.id, false))),
),
)
// subscribe : s’abonne au flux pour recevoir ses valeurs
.subscribe({
next: () => {
this.confirmation.set(`${medias.length} média(s) emprunté(s). Bonne lecture !`);
this.selection.clear();
},
error: () => this.error.set('Le prêt a échoué. Réessayez.'),
});
}
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} / {{ max }} médias</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<li>{{ m.title }} <button class="btn btn-secondary" (click)="selection.remove(m.id)">✕</button></li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li class="muted">Aucun média choisi.</li>
}
</ul>
<button class="btn btn-secondary" (click)="selection.clear()">Vider</button>
<button class="btn" (click)="validate()" [disabled]="selection.count() === 0">
Valider mon emprunt
</button>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (confirmation(); as message) {
<p class="ok">{{ message }}</p>
}
@if (error(); as message) {
<p class="error">{{ message }}</p>
}
</aside>/* bouton d'action destructrice */
.btn-danger { background: var(--bad); border-color: var(--bad); color: #fff; }
.btn-danger:hover { filter: brightness(0.9); }<h2>Gérer les médias</h2>
<app-media-form />
<table class="table">
<thead><tr><th>Titre</th><th>Auteur</th><th>Type</th><th>Disponible</th><th>Actions</th></tr></thead>
<tbody>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of mediaService.getAll(); track m.id) {
<tr>
<!-- {{ }} : affiche la valeur de la classe -->
<td>{{ m.title }}</td>
<td>{{ m.author }}</td>
<td>{{ m.type }}</td>
<td>{{ m.available ? 'Oui' : 'Non' }}</td>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<td><button class="btn btn-danger" (click)="mediaService.remove(m.id)">Supprimer</button></td>
</tr>
}
</tbody>
</table>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Complément · 10 / 19
Organiser, optimiser, déboguer
Chapitre 29 · Organiser, optimiser, déboguer
Organiser le projet
Structurer un projet Angular de taille réelle, utiliser les commandes du CLI et adopter des conventions de nommage claires.
Glossaire du chapitre
- core
- Dossier des éléments utilisés une seule fois dans toute l’application : services globaux, garde-fous.
- shared
- Dossier des composants et pipes réutilisables dans plusieurs pages.
- features
- Dossier d’une fonctionnalité complète : ses pages, ses services, ses routes.
- ng generate
- Commande (
ng g) qui crée un composant, un service ou une route avec la bonne structure. - kebab-case
- Style de nommage des fichiers : des minuscules séparées par des tirets, par exemple
media-card.ts.
Comprendre : organiser un projet
Ce qui sera montré : Selon la taille du projet :
- pour un petit projet, un seul dossier
app/suffit ; - dès que l'application grandit, on regroupe le code par fonctionnalité (médias, sélection, compte) plutôt que par type de fichier, pour trouver tout ce qui concerne un sujet au même endroit.
Au programme : 29.1 Une structure par fonctionnalités · 29.2 Mettre la logique dans les services · 29.3 Les commandes ng generate · 29.4 Conventions de nommage
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 29.4.A Réorganiser un dossierEssentiel
Un collègue a laissé ces fichiers à plat dans src/app/ : MediaList.ts, selection.ts, api-config.ts, Highlight.ts, media.service.ts, SelectionBadge.ts.
Proposez une arborescence respectant les conventions de ce chapitre, et indiquez pour chaque fichier son dossier et son nouveau nom.
Voir la solution
features/medias/media-list.ts(le nom de la classe devientMediaList).features/medias/media.service.ts(déjà correct, il change seulement de dossier).features/selection/selection.service.ts(renommé depuisselection.ts).features/selection/selection-badge.ts(renommé depuisSelectionBadge.ts).core/api.config.ts(configuration globale, à partager dans toute l'application).shared/highlight.ts(directive réutilisable, renommée en kebab-case).
Les noms de fichiers passent en minuscules kebab-case, les noms de classes restent en PascalCase.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 29
Quelle structure est recommandée pour un projet qui grandit ?
Regrouper par fonctionnalité garde le code d'un sujet au même endroit.
Quelle règle distingue un composant d'un service ?
La séparation affichage / logique rend le code testable et réutilisable.
Quelle commande crée un service ?
ng generate service (ou ng g s) crée le fichier avec la structure attendue.
Quelle convention de nommage pour les fichiers ?
Les fichiers en kebab-case sont la convention Angular.
Quel dossier accueille les composants réutilisables dans tout le projet ?
Le dossier shared regroupe ce qui est partagé entre plusieurs fonctionnalités.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 30 · Organiser, optimiser, déboguer
Déboguer une application Angular
Trouver et corriger un bug : lire la console, utiliser Angular DevTools, poser des points d'arrêt dans PhpStorm, et comprendre les messages d'erreur les plus courants.
Glossaire du chapitre
- Angular DevTools
- Extension du navigateur qui affiche l’arbre des composants, leurs entrées et leurs signaux.
- Point d’arrêt
- Ligne où le débogueur s’arrête pour examiner les variables.
- Stack trace
- Liste des appels qui ont mené à une erreur. On la lit de haut en bas, en cherchant son propre code.
- NullInjectorError
- Erreur de DI : un service demandé n’a pas été fourni.
- NG0304
- Code d’erreur : un élément HTML n’est pas connu, souvent parce qu’un composant n’est pas importé.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous fabriquons volontairement un bug (un composant recipe-card dont l'entrée est mal nommée) pour apprendre à le retrouver.
Au programme : 30.1 La console et les messages d'erreur · 30.2 Angular DevTools · 30.3 Points d'arrêt dans PhpStorm · 30.4 Lire les erreurs courantes
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 30.4.A Provoquer et corriger une erreurEssentiel
Dans demo :
- Créez un composant
recipe-cardavec une entréerecipe = input.required<string>()et le template<p>{{ recipe() }}</p>. - Dans
app.html, utilisez-le avec une faute :<app-recipe-card [recette]="'Crêpes'" />. - Lisez le message d'erreur dans la console. Quelle est la cause ?
- Corrigez-le.
Voir la solution
La console affiche Can't bind to 'recette' since it isn't a known property of 'app-recipe-card'. La cause : le parent écrit [recette] alors que l'enfant déclare recipe. Il faut aligner les deux noms :
<app-recipe-card [recipe]="'Crêpes'" />Ce même message apparaît aussi quand on a oublié d'importer FormsModule pour un [(ngModel)] : le nom est correct, mais la directive manque. Vérifiez toujours ces deux pistes.
Étape 29 Retrouver un bug dans Médiathèque
Ce qui est réalisé : Cette étape n'ajoute rien à l'écran : elle vous entraîne à retrouver la cause d'un bug dans Médiathèque, avec la console et un point d'arrêt. Vous provoquez deux erreurs volontaires, puis vous les corrigez.
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 30
Quel élément de la console est le plus utile pour trouver l'origine d'une erreur ?
La pile d'appels montre le chemin qui a conduit à l'erreur.
À quoi sert Angular DevTools ?
C'est l'outil d'inspection spécifique à Angular.
Que fait un point d'arrêt dans PhpStorm ?
Le point d'arrêt fige le programme pour l'analyser pas à pas.
Que signifie
NullInjectorError: No provider for X?Le service doit être décoré par
@Service() ou déclaré dans providers.Dans un message d'erreur en cascade, quelle erreur faut-il corriger en premier ?
Corriger la cause première supprime souvent les erreurs suivantes.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Niveau
Formation avancée
La suite, pour structurer, optimiser et fiabiliser une application Angular existante.
Partie 10 · 11 / 19
Positionnement et rappels
Chapitre 31 · Positionnement et rappels
Démarrer le module avancé
Faire le point sur vos acquis Angular, revoir en quelques minutes les notions indispensables, et savoir exactement ce que le module avancé va vous apporter.
Testez-vous : QCM 9 questions✓
QCM · Chapitre 31
Pour afficher une liste dans un template Angular 22, quelle syntaxe utilisez-vous ?
La syntaxe
@for(avectrack) remplace*ngFordepuis Angular 17.Quel est le rôle de
inject()?inject()remplace le constructeur pour récupérer une dépendance, y compris dans les champs de classe.Quelle différence entre un signal et un Observable ?
Un signal se lit à tout moment ; un Observable se souscrit et délivre une suite de valeurs.
Que fait
switchMaplorsqu'une nouvelle valeur arrive alors qu'une requête précédente est encore en cours ?C'est l'opérateur idéal pour une recherche : seul le dernier résultat compte.
Quel est le rôle de
loadComponentdans une route ?C'est la base du chargement à la demande (lazy loading).
À quoi sert un
FormArray?Un
FormArraypermet d'ajouter ou de retirer des lignes, par exemple les médias d'un emprunt.Que signifie
ChangeDetectionStrategy.OnPush?C'est une optimisation : Angular vérifie moins de composants à chaque cycle.
Quel test d'intégration vérifie qu'une requête HTTP part bien ?
HttpTestingControllerpermet d'intercepter les requêtes et de répondre à leur place.Pourquoi utiliser
takeUntilDestroyed()?Sans cela, un abonnement peut continuer à fonctionner après la disparition de l'écran.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 11 · 12 / 19
Aller plus loin
Chapitre 32 · Aller plus loin
Composants avancés : projection et références
Créer des composants qui acceptent du contenu HTML de l'extérieur, réutiliser un gabarit, et accéder à un élément du template depuis la classe.
Glossaire du chapitre
- Projection de contenu
- Le parent écrit du contenu entre les balises d’un composant, et le composant l’affiche à sa place.
- ng-content
- Emplacement où s’affiche le contenu projeté.
- ng-template
- Gabarit HTML décrit sans être affiché, réutilisable avec
ngTemplateOutlet. - viewChild()
- Accès à un élément du template depuis la classe, sous forme de signal.
- Référence de template
- Nom donné à un élément avec
#nom, pour le retrouver depuis la classe.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous créons un encart box (une boîte avec un titre) qui affiche une « astuce du jour », et un champ de saisie dont le focus est donné depuis le parent.
Au programme : 32.1 La projection de contenu : ng-content · 32.2 ng-template et ngTemplateOutlet · 32.3 viewChild() : accéder à un élément
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 32.3.A Un bouton qui donne le focusEssentiel
Dans App, affichez <app-quick-note /> et un bouton Écrire une note qui place le curseur dans le champ.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { viewChild } from '@angular/core';
import { QuickNote } from '../quick-note/quick-note';
// viewChild : accès à un élément du template depuis la classe
note = viewChild.required(QuickNote);
writeNote(): void {
this.note().focus();
}<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="writeNote()">Écrire une note</button>
<app-quick-note />N'oubliez pas d'ajouter QuickNote à imports de App. viewChild(QuickNote) accepte aussi une classe de composant, et pas seulement un #nom.
Étape 30 Un encart et le focus de la recherche
Ce qui est réalisé : Nous ajoutons à Médiathèque un encart réutilisable (un cadre avec un titre) pour « Disponibles maintenant » sur l'accueil, et un bouton Rechercher sur le catalogue qui place le curseur dans la barre de recherche.
Fichiers concernés : src/app/panel/panel.html · src/app/panel/panel.ts · src/app/panel/panel.css · src/app/home/home.ts · src/app/home/home.html · src/app/available-now/available-now.html · src/app/media-search/media-search.ts · src/app/media-search/media-search.html · src/app/catalogue/catalogue.ts · src/app/catalogue/catalogue.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 32
Que permet
<ng-content />?C'est l'emplacement de la projection de contenu.
Quel attribut de
<ng-content>choisit une partie précise du contenu ?select filtre le contenu projeté selon un sélecteur CSS.
À quoi sert
<ng-template>?Un ng-template est un gabarit inactif, affiché avec ngTemplateOutlet.
Quelle est la version moderne de @ViewChild ?
viewChild()renvoie un signal, cohérent avec le reste de l'API moderne.Pourquoi limiter l'accès direct au DOM ?
Le DOM direct contourne la logique réactive d'Angular.
Code de Médiathèque à la fin du chapitre (10 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
<section class="panel">
<header>
<!-- ng-content : emplacement du contenu fourni par le parent -->
<ng-content select="h2" />
</header>
<div class="panel-body">
<ng-content />
</div>
</section>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-panel',
templateUrl: './panel.html',
styleUrl: './panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Panel {}.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: 1rem 1.25rem;
margin-block: 1.5rem;
}
.panel header {
border-bottom: 1px solid var(--line);
margin-bottom: 0.75rem;
}
.panel header h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.panel-body {
color: var(--text);
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AvailableNow } from '../available-now/available-now';
import { Panel } from '../panel/panel';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-home',
imports: [RouterLink, AvailableNow, Panel],
templateUrl: './home.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Home {}<section class="hero">
<h1>Bienvenue à la médiathèque</h1>
<p>Recherchez un livre, un film ou un jeu, et empruntez-le en quelques clics.</p>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/catalogue">Voir le catalogue</a>
<a routerLink="/catalogue" [queryParams]="{ q: 'dune' }">Chercher Dune</a>
</section>
<app-panel>
<h2>Disponibles maintenant</h2>
<app-available-now />
</app-panel><!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" (click)="medias.reload()">Recharger</button>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (medias.isLoading()) {
<p>Chargement...</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else if (medias.error()) {
<p class="error">Impossible de charger les médias.</p>
} @else {
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of available(); track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ m.title }} ({{ m.author }})</li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li>Aucun média disponible.</li>
}
</ul>
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, ElementRef, effect, inject, input, viewChild } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, startWith, switchMap } from 'rxjs';
import { MediaService } from '../media.service';
import { MediaCard } from '../media-card/media-card';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-search',
imports: [AsyncPipe, ReactiveFormsModule, MediaCard],
templateUrl: './media-search.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaSearch {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
q = input<string>(); // terme de départ (?q=... du catalogue)
search = new FormControl('', { nonNullable: true });
// viewChild : accès à un élément du template depuis la classe
searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
results$ = this.search.valueChanges.pipe(
startWith(''),
// debounceTime : attend une pause de saisie avant d’agir
debounceTime(300),
// distinctUntilChanged : ignore une valeur identique à la précédente
distinctUntilChanged(),
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap((term) => this.mediaService.searchApi(term)),
);
constructor() {
// effect : code relancé à chaque changement des signaux qu’il lit
effect(() => this.search.setValue(this.q() ?? '')); // le champ repart de q
}
focus(): void {
this.searchInput().nativeElement.focus();
}
}<div class="search">
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<input #searchInput [formControl]="search" placeholder="Rechercher un titre, un auteur...">
</div>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (results$ | async; as list) {
@if (list.length === 0) {
<p>Aucun résultat.</p>
}
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of list; track m.id) {
<app-media-card [media]="m" />
}
</section>
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input, viewChild } from '@angular/core';
import { MediaSearch } from '../media-search/media-search';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-catalogue',
imports: [MediaSearch],
templateUrl: './catalogue.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Catalogue {
q = input<string>(); // reçoit ?q=... dans l'adresse
// viewChild : accès à un élément du template depuis la classe
search = viewChild.required(MediaSearch);
goToSearch(): void {
this.search().focus();
}
}<h1>Catalogue</h1>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" (click)="goToSearch()">Rechercher</button>
<app-media-search [q]="q()" />Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 33 · Aller plus loin
Les pipes : transformer l'affichage
Utiliser les pipes intégrés pour formater dates, nombres et textes dans le template.
Glossaire du chapitre
- Pipe
- Transformation de valeur au moment de l’affichage, écrite avec
|. - Locale
- Ensemble de règles régionales : format des nombres, des dates, symbole monétaire.
- currency / date
- Pipes intégrés qui formatent un montant ou une date.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous formatons des données de recettes : durée de préparation, date, note.
Au programme : 33.1 Les pipes intégrés
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
Étape 31 Des dates lisibles : le pipe date
Ce qui est réalisé : Nous affichons maintenant une information de Médiathèque de façon lisible : la date de retour prévue de la sélection, en toutes lettres et en français (« 12 janvier 2027 » plutôt qu'une date brute).
Fichiers concernés : src/app/app.config.ts · src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 3 questions✓
QCM · Chapitre 33
Que fait un pipe dans un template ?
Un pipe ne modifie que l'affichage.
Quelle est la syntaxe d'un pipe avec un paramètre ?
Le paramètre suit deux-points après le nom du pipe.
Pourquoi enregistrer la locale fr ?
La locale règle les formats régionaux.
Code de Médiathèque à la fin du chapitre (3 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
registerLocaleData(localeFr);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// provideRouter : active le routeur avec la liste des routes
provideRouter(routes, withComponentInputBinding()),
// provideHttpClient : active HttpClient pour appeler des API
provideHttpClient(),
{ provide: LOCALE_ID, useValue: 'fr' },
],
};import { DatePipe } from '@angular/common';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, signal } from '@angular/core';
import { forkJoin, switchMap } from 'rxjs';
import { LoanService } from '../loan.service';
import { MediaService } from '../media.service';
import { MAX_LOANS } from '../models/media';
import { SelectionService } from '../selection.service';
import { returnDate } from '../utils/loan-rules';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
imports: [DatePipe],
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
private loanService = inject(LoanService);
private mediaService = inject(MediaService);
protected max = MAX_LOANS;
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
dueDate = computed(() => {
const longest = Math.max(...this.selection.list().map((m) => m.loanDays));
return returnDate(new Date(), longest);
});
confirmation = signal<string | null>(null);
error = signal<string | null>(null);
validate(): void {
const medias = this.selection.list();
if (medias.length === 0) return;
const today = new Date();
const longest = Math.max(...medias.map((m) => m.loanDays));
this.loanService
.create({
mediaIds: medias.map((m) => m.id),
date: today.toISOString().slice(0, 10),
returnDate: returnDate(today, longest).toISOString().slice(0, 10),
})
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
.pipe(
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap(() =>
forkJoin(medias.map((m) => this.mediaService.setAvailable(m.id, false))),
),
)
// subscribe : s’abonne au flux pour recevoir ses valeurs
.subscribe({
next: () => {
this.confirmation.set(`${medias.length} média(s) emprunté(s). Bonne lecture !`);
this.selection.clear();
},
error: () => this.error.set('Le prêt a échoué. Réessayez.'),
});
}
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} / {{ max }} médias</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<li>{{ m.title }} <button class="btn btn-secondary" (click)="selection.remove(m.id)">✕</button></li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li class="muted">Aucun média choisi.</li>
}
</ul>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (selection.count() > 0) {
<p class="muted">Retour prévu le {{ dueDate() | date:'longDate' }}</p>
}
<button class="btn btn-secondary" (click)="selection.clear()">Vider</button>
<button class="btn" (click)="validate()" [disabled]="selection.count() === 0">
Valider mon emprunt
</button>
@if (confirmation(); as message) {
<p class="ok">{{ message }}</p>
}
@if (error(); as message) {
<p class="error">{{ message }}</p>
}
</aside>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 34 · Aller plus loin
Formulaires dynamiques : FormArray
Gérer une liste de champs que l'utilisateur peut ajouter ou retirer, et écrire un validateur asynchrone qui interroge le serveur.
Glossaire du chapitre
- FormArray
- Liste de contrôles de taille variable, qu’on peut ajouter ou retirer.
- Validateur asynchrone
- Validateur qui interroge le serveur et renvoie un Observable.
- Anti-rebond
- Attente d’une pause avant d’agir, pour éviter une requête à chaque lettre.
- push / removeAt
- Méthodes d’un FormArray : ajouter un contrôle en fin de liste, ou en retirer un à une position donnée.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous construisons un formulaire de recette dont le nombre d'ingrédients est variable, et un champ pseudo dont l'unicité est vérifiée auprès de JSONPlaceholder (GET /users?username=…).
Au programme : 34.1 FormArray : une liste de contrôles · 34.2 Un validateur asynchrone
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 34.2.A Un pseudo uniqueEssentiel
Dans recipe-form, ajoutez un champ username avec le validateur asynchrone ci-dessus. Affichez « Pseudo déjà pris. » si l'erreur taken est présente, et « Vérification... » pendant l'attente.
Voir la solution
import { Validators } from '@angular/forms';
import { uniqueUsername } from '../validators/unique-username';
// FormBuilder : construit le formulaire à partir de sa description
form = this.fb.nonNullable.group({
name: [''],
// Validators : validateurs intégrés d’Angular
username: ['', [Validators.required], [uniqueUsername()]],
ingredients: this.fb.nonNullable.array<string>([]),
});<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<input formControlName="username" placeholder="Pseudo">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.username.pending) {
<small>Vérification...</small>
}
@if (form.controls.username.hasError('taken')) {
<small>Pseudo déjà pris.</small>
}Étape 32 Mots-clés et titre unique
Ce qui est réalisé : Nous améliorons le formulaire d'ajout d'un média du bibliothécaire (étape 19) : une liste de mots-clés que l'on peut ajouter et retirer (FormArray), et un titre unique vérifié auprès de l'API (validateur asynchrone).
Fichiers concernés : src/app/models/media.ts · src/app/validators/unique-title.ts · src/app/admin/media-form/media-form.ts · src/app/admin/media-form/media-form.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 34
Quand utilise-t-on un FormArray ?
Un FormArray gère une collection de contrôles ajoutables ou retirables.
Que renvoie un validateur asynchrone ?
La validation asynchrone attend la réponse du serveur.
Pourquoi ajouter une pause (
timer(400)) avant la requête ?C'est un anti-rebond, comparable à debounceTime.
Quelle relation entre
track $indexet un FormArray ici ?Sans réordonnement, la position suffit à identifier chaque contrôle.
Pourquoi
inject()doit-il être appelé au bon moment ?inject()dépend du contexte d'injection d'Angular.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
export type MediaType = 'livre' | 'film' | 'jeu';
// Interface : décrit la forme d’un objet ; elle disparaît à l’exécution
export interface Media {
id: number;
title: string;
author: string;
year: number;
type: MediaType;
genre: string;
available: boolean;
rating?: number;
loanDays: number;
keywords?: string[];
}
export const MAX_LOANS = 5;// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { map, catchError, of, timer, switchMap } from 'rxjs';
import { API_URL } from '../core/config';
export function uniqueTitle(): AsyncValidatorFn {
// inject() : demande au conteneur une instance du service
const http = inject(HttpClient);
return (control: AbstractControl) =>
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
timer(400).pipe(
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap(() => http.get<unknown[]>(`${API_URL}/medias`, { params: { title: control.value } })),
map((found) => (found.length > 0 ? { taken: true } : null)),
// catchError : traite l’erreur pour que le flux puisse continuer
catchError(() => of(null)),
);
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MediaService } from '../../media.service';
import { MediaType } from '../../models/media';
import { uniqueTitle } from '../../validators/unique-title';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-form',
imports: [ReactiveFormsModule],
templateUrl: './media-form.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaForm {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
private mediaService = inject(MediaService);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
title: ['', [Validators.required], [uniqueTitle()]],
author: ['', Validators.required],
year: [2000, [Validators.required, Validators.min(1000), Validators.max(2100)]],
type: ['livre' as MediaType, Validators.required],
genre: ['', Validators.required],
loanDays: [14, [Validators.required, Validators.min(1)]],
available: [true],
keywords: this.fb.nonNullable.array<string>([]),
});
addKeyword(): void {
this.form.controls.keywords.push(this.fb.nonNullable.control(''));
}
removeKeyword(index: number): void {
this.form.controls.keywords.removeAt(index);
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.mediaService.add(this.form.getRawValue());
this.form.controls.keywords.clear();
this.form.reset();
}
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="title">Titre</label>
<input id="title" formControlName="title">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.title.touched && form.controls.title.hasError('required')) {
<p class="error">Le titre est obligatoire.</p>
}
@if (form.controls.title.pending) {
<p class="muted">Vérification...</p>
}
@if (form.controls.title.hasError('taken')) {
<p class="error">Ce titre existe déjà.</p>
}
</div>
<div class="field">
<label for="author">Auteur ou réalisateur</label>
<input id="author" formControlName="author">
@if (form.controls.author.touched && form.controls.author.invalid) {
<p class="error">L'auteur est obligatoire.</p>
}
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" formControlName="type">
<option value="livre">Livre</option>
<option value="film">Film</option>
<option value="jeu">Jeu</option>
</select>
</div>
<div class="field">
<label for="genre">Genre</label>
<input id="genre" formControlName="genre">
@if (form.controls.genre.touched && form.controls.genre.invalid) {
<p class="error">Le genre est obligatoire.</p>
}
</div>
<div class="field">
<label for="year">Année</label>
<input id="year" type="number" formControlName="year">
@if (form.controls.year.touched && form.controls.year.invalid) {
<p class="error">L'année doit être comprise entre 1000 et 2100.</p>
}
</div>
<div class="field">
<label for="loanDays">Durée de prêt (jours)</label>
<input id="loanDays" type="number" formControlName="loanDays">
@if (form.controls.loanDays.touched && form.controls.loanDays.invalid) {
<p class="error">La durée de prêt est d'au moins 1 jour.</p>
}
</div>
<label><input type="checkbox" formControlName="available"> Disponible</label>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (kw of form.controls.keywords.controls; track $index; let i = $index) {
<div class="field">
<input [formControl]="kw" placeholder="Mot-clé">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" type="button" (click)="removeKeyword(i)">Retirer</button>
</div>
}
<button class="btn" type="button" (click)="addKeyword()">+ Ajouter un mot-clé</button>
<p><button class="btn" type="submit">Ajouter</button></p>
</form>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 35 · Aller plus loin
Préparer la liaison avec Spring Boot
Anticiper le passage d'un json-server à un backend réel : proxy de développement, configuration de l'adresse de l'API, notion de CORS et place de l'authentification.
Glossaire du chapitre
- Proxy de développement
- Serveur intermédiaire qui transmet les appels
/apiau backend, sur la même origine. - CORS
- Mécanisme du navigateur qui bloque une réponse venant d’une autre origine, sauf autorisation du serveur.
- JWT
- Jeton signé qui prouve l’identité de la personne connectée. Il voyage dans l’en-tête
Authorization. - Intercepteur d’authentification
- Intercepteur qui ajoute le jeton à toutes les requêtes.
- Spring Boot
- Framework Java pour créer des API REST. Il sera le backend de la formation suivante.
Comprendre : de json-server à Spring Boot
Ce qui sera montré : Cette partie est surtout de la compréhension : elle explique ce qui change quand le vrai back-end remplace json-server. Les configurations montrées sont génériques ; vous les appliquerez à Médiathèque dans l'étape 33.
Au programme : 35.1 Une seule adresse à changer · 35.2 Le proxy de développement · 35.3 CORS : pourquoi le navigateur bloque · 35.4 Authentification : l'aperçu · 35.5 Qui a le droit de quoi ?
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
Étape 33 Brancher le proxy et écrire le contrat d'API
Ce qui est réalisé : Dans cette étape, nous préparons la rencontre entre Médiathèque (Angular) et votre API Spring Boot :
- l'adresse de l'API passe par un proxy ;
- nous nous mettons d'accord avec le back-end sur un contrat d'API : la liste des adresses, des formats JSON et des droits.
Fichiers concernés : src/app/core/config.ts · proxy.conf.json
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 35
Que fait le proxy de développement ?
Le proxy évite les problèmes d'origine différente pendant le développement.
Que signifie CORS ?
CORS contrôle le partage de ressources entre origines différentes.
Où est-il préférable de définir l'adresse de base de l'API ?
Une constante partagée permet de changer d'adresse à un seul endroit.
Pourquoi centraliser les appels HTTP dans les services ?
Un service isole la dépendance à l'API.
Que se passe-t-il si l'en-tête Authorization est absent d'une requête protégée ?
La sécurité est appliquée côté serveur.
Code de Médiathèque à la fin du chapitre (2 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
export const API_URL = '/api';{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"pathRewrite": { "^/api": "" }
}
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 36 · Aller plus loin
Projet final : valider son emprunt sur une page dédiée
Remplacer le bouton de validation par une vraie page d'emprunt : un guard qui exige une sélection non vide, un formulaire validé et l'envoi à l'API.
Glossaire du chapitre
- Sélection
- Liste des médias choisis par le lecteur, avant la validation de l’emprunt.
- Guard de sélection
- Garde qui empêche d’ouvrir la page d’emprunt si la sélection est vide.
- requiredTrue
- Validateur qui exige qu’une case à cocher soit cochée (par exemple l’acceptation du règlement).
- Règle côté serveur
- Le serveur vérifie le quota et la disponibilité avant d’enregistrer l’emprunt : il ne fait pas confiance au navigateur.
Étape 34 La page d'emprunt
Ce qui est réalisé : Nous remplaçons le bouton de validation du panneau par une page dédiée /emprunt. Le guard, la page et la route sont créés dans un dossier src/app/loan/.
Fichiers concernés : src/app/loan/selection.guard.ts · src/app/app.routes.ts · src/app/loan/loan-page.ts · src/app/loan/loan-page.html · src/app/selection-panel/selection-panel.ts · src/app/selection-panel/selection-panel.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 36
Pourquoi le guard
selectionNotEmptyGuardredirige-t-il vers le catalogue ?Le guard évite d'afficher une page inutile.
Pourquoi afficher la confirmation dans la page elle-même, avec un signal ?
Après le vidage de la sélection, la route n'est plus accessible.
Que fait
Validators.requiredTruesur une case à cocher ?requiredTrue est fait pour les cases d'acceptation.
Qui doit garantir qu'un emprunt ne dépasse pas 5 médias ?
Toute règle métier doit être vérifiée côté serveur.
Code de Médiathèque à la fin du chapitre (6 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { inject } from '@angular/core';
// Garde : décide si la route peut être ouverte
import { CanActivateFn, Router } from '@angular/router';
import { SelectionService } from '../selection.service';
export const selectionNotEmptyGuard: CanActivateFn = () => {
// inject() : demande au conteneur une instance du service
const selection = inject(SelectionService);
const router = inject(Router);
// createUrlTree : construit une redirection vers une autre route
return selection.count() > 0 ? true : router.createUrlTree(['/catalogue']);
};import { Routes } from '@angular/router';
import { Home } from './home/home';
import { Catalogue } from './catalogue/catalogue';
import { MediaDetail } from './media-detail/media-detail';
import { Loans } from './loans/loans';
import { Contact } from './contact/contact';
import { AdminLayout } from './admin/admin-layout/admin-layout';
import { Dashboard } from './admin/dashboard/dashboard';
import { AdminMedias } from './admin/admin-medias/admin-medias';
import { Login } from './login/login';
import { Signup } from './signup/signup';
import { librarianGuard } from './auth/auth.guard';
import { selectionNotEmptyGuard } from './loan/selection.guard';
import { LoanPage } from './loan/loan-page';
import { NotFound } from './not-found/not-found';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'catalogue', component: Catalogue },
{ path: 'catalogue/:id', component: MediaDetail },
{ path: 'emprunts', component: Loans },
// Garde : décide si la route peut être ouverte
{ path: 'emprunt', component: LoanPage, canActivate: [selectionNotEmptyGuard] },
{ path: 'contact', component: Contact },
{ path: 'login', component: Login },
{ path: 'inscription', component: Signup },
{
path: 'admin',
canMatch: [librarianGuard],
component: AdminLayout,
children: [
{ path: '', component: Dashboard },
{ path: 'medias', component: AdminMedias },
],
},
{ path: '**', component: NotFound },
];// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject, signal } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { forkJoin, switchMap } from 'rxjs';
import { LoanService } from '../loan.service';
import { MediaService } from '../media.service';
import { SelectionService } from '../selection.service';
import { returnDate } from '../utils/loan-rules';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-loan-page',
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './loan-page.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class LoanPage {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
private loanService = inject(LoanService);
private mediaService = inject(MediaService);
protected selection = inject(SelectionService);
done = signal<string | null>(null);
error = signal<string | null>(null);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
place: ['', Validators.required],
rules: [false, Validators.requiredTrue],
});
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const medias = this.selection.list();
const today = new Date();
const longest = Math.max(...medias.map((m) => m.loanDays));
this.loanService
.create({
mediaIds: medias.map((m) => m.id),
date: today.toISOString().slice(0, 10),
returnDate: returnDate(today, longest).toISOString().slice(0, 10),
})
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
.pipe(
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap(() => forkJoin(medias.map((m) => this.mediaService.setAvailable(m.id, false)))),
)
// subscribe : s’abonne au flux pour recevoir ses valeurs
.subscribe({
next: () => {
this.done.set(`Emprunt enregistré. Retrait : ${this.form.controls.place.value}.`);
this.selection.clear();
},
error: () => this.error.set("L'emprunt a échoué. Réessayez."),
});
}
}<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (done(); as message) {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="ok">{{ message }}</p>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/catalogue">Retour au catalogue</a>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<h1>Valider mon emprunt</h1>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<li>{{ m.title }} ({{ m.loanDays }} jours)</li>
}
</ul>
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="place">Lieu de retrait</label>
<select id="place" formControlName="place">
<option value="">Choisir...</option>
<option>Médiathèque centrale</option>
<option>Annexe du centre-ville</option>
</select>
@if (form.controls.place.touched && form.controls.place.invalid) {
<p class="error">Choisissez un lieu de retrait.</p>
}
</div>
<label><input type="checkbox" formControlName="rules"> J'accepte le règlement</label>
@if (form.controls.rules.touched && form.controls.rules.invalid) {
<p class="error">Vous devez accepter le règlement.</p>
}
<p><button class="btn" type="submit">Confirmer</button></p>
@if (error(); as message) {
<p class="error" role="alert">{{ message }}</p>
}
</form>
}import { DatePipe } from '@angular/common';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MAX_LOANS } from '../models/media';
import { SelectionService } from '../selection.service';
import { returnDate } from '../utils/loan-rules';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-selection-panel',
imports: [DatePipe, RouterLink],
templateUrl: './selection-panel.html',
styleUrl: './selection-panel.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionPanel {
// inject() : demande au conteneur une instance du service
protected selection = inject(SelectionService);
protected max = MAX_LOANS;
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
dueDate = computed(() => {
const longest = Math.max(...this.selection.list().map((m) => m.loanDays));
return returnDate(new Date(), longest);
});
}<aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} / {{ max }} médias</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<li>{{ m.title }} <button class="btn btn-secondary" (click)="selection.remove(m.id)">✕</button></li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li class="muted">Aucun média choisi.</li>
}
</ul>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (selection.count() > 0) {
<p class="muted">Retour prévu le {{ dueDate() | date:'longDate' }}</p>
}
<button class="btn btn-secondary" (click)="selection.clear()">Vider</button>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/emprunt">Valider ma sélection</a>
</aside>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 37 · Aller plus loin
Architecture : composants de présentation et composants de page
Séparer ce qui affiche (présentation) de ce qui décide et charge les données (page), pour obtenir des composants simples à réutiliser et à tester.
Glossaire du chapitre
- Composant de présentation
- Composant qui affiche ses entrées et émet des événements, sans connaître les services.
- Composant de page
- Composant qui charge les données, gère la logique et transmet les valeurs aux composants de présentation.
- Séparation des responsabilités
- Chaque composant a un seul rôle clair : afficher, ou décider.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous séparons un composant d'affichage recipe-tile d'un composant de page recipes-page qui gère une liste de favoris.
Au programme : 37.1 Deux familles de composants · 37.2 Exemple : la tuile et la page
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 37.2.A Classer des composantsEssentiel
Pour chaque composant suivant, dites s'il s'agit d'un composant de présentation ou de page :
- un bouton
btnréutilisable ; - un tableau qui appelle un service pour charger ses lignes ;
- une fiche qui lit l'identifiant dans l'URL ;
- une liste qui reçoit un tableau en entrée et émet « supprimer ».
Voir la solution
Bouton réutilisable → présentation. Tableau qui appelle un service → page. Fiche qui lit l'URL → page. Liste qui reçoit un tableau et émet « supprimer » → présentation. Pour les cas intermédiaires, les deux choix sont défendables : l'important est de choisir, puis de s'y tenir.
Étape 35 Rendre la carte pure
Ce qui est réalisé : À l'étape 12, la carte MediaCard a été reliée au SelectionService pour simplifier le code. Nous la rendons maintenant pure : elle reçoit, elle émet.
Fichiers concernés : src/app/media-card/media-card.ts · src/app/media-card/media-card.html · src/app/media-search/media-search.ts · src/app/media-search/media-search.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 37
Qu'est-ce qu'un composant de présentation ?
Il est autonome et réutilisable.
Quel est le rôle d'un composant de page ?
C'est le point de coordination de l'écran.
Quel avantage principal de cette séparation ?
Chaque composant a une responsabilité claire.
Comment la page catalogue réagit-elle à l'emprunt d'un média ?
La page joue le rôle de chef d'orchestre.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, booleanAttribute, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Highlight } from '../highlight';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
imports: [Highlight, RouterLink],
templateUrl: './media-card.html',
styleUrl: './media-card.css',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
// input() : valeur fournie par le parent ; on la lit avec ()
compact = input(false, { transform: booleanAttribute });
selected = input(false);
disabled = input(false); // par exemple : la sélection est pleine
borrowRequested = output<Media>();
}<article class="card" appHighlight
[class.card--out]="!media().available"
[class.card--top]="(media().rating ?? 0) >= 4.5">
<!-- routerLink : lien interne, sans rechargement de la page -->
<h2><a [routerLink]="['/catalogue', media().id]">{{ media().title }}</a></h2>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (!compact()) {
<!-- {{ }} : affiche la valeur de la classe -->
<p class="author">{{ media().author }} · {{ media().year }}</p>
}
<div class="meta">
<span class="badge">
<!-- @switch : affiche le bloc qui correspond à la valeur -->
@switch (media().type) {
@case ('livre') { 📖 }
@case ('film') { 🎬 }
@case ('jeu') { 🎮 }
}
{{ media().type }}
</span>
@if (media().available) {
<span class="badge badge-ok">Disponible</span>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<span class="badge badge-bad">Indisponible</span>
}
@if (media().year >= 2016) {
<span class="badge badge-warn">Nouveau</span>
}
</div>
<button class="btn"
[disabled]="!media().available || disabled() || selected()"
(click)="borrowRequested.emit(media())">
{{ selected() ? 'Dans ma sélection' : 'Emprunter' }}
</button>
</article>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, ElementRef, effect, inject, input, viewChild } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, startWith, switchMap } from 'rxjs';
import { MediaService } from '../media.service';
import { MediaCard } from '../media-card/media-card';
import { Media } from '../models/media';
import { SelectionService } from '../selection.service';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-search',
imports: [AsyncPipe, ReactiveFormsModule, MediaCard],
templateUrl: './media-search.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaSearch {
// inject() : demande au conteneur une instance du service
private mediaService = inject(MediaService);
protected selection = inject(SelectionService);
q = input<string>(); // terme de départ (?q=... du catalogue)
search = new FormControl('', { nonNullable: true });
// viewChild : accès à un élément du template depuis la classe
searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
results$ = this.search.valueChanges.pipe(
startWith(''),
// debounceTime : attend une pause de saisie avant d’agir
debounceTime(300),
// distinctUntilChanged : ignore une valeur identique à la précédente
distinctUntilChanged(),
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap((term) => this.mediaService.searchApi(term)),
);
constructor() {
// effect : code relancé à chaque changement des signaux qu’il lit
effect(() => this.search.setValue(this.q() ?? '')); // le champ repart de q
}
focus(): void {
this.searchInput().nativeElement.focus();
}
onBorrow(media: Media): void {
this.selection.add(media);
}
}<div class="search">
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<input #searchInput [formControl]="search" placeholder="Rechercher un titre, un auteur...">
</div>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (results$ | async; as list) {
@if (list.length === 0) {
<p>Aucun résultat.</p>
}
<section class="grid">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of list; track m.id) {
<app-media-card
[media]="m"
[selected]="selection.has(m.id)"
[disabled]="selection.isFull()"
(borrowRequested)="onBorrow($event)" />
}
</section>
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 38 · Aller plus loin
Accessibilité de base
Rendre l'application utilisable par tous : textes alternatifs, champs correctement étiquetés, messages annoncés aux lecteurs d'écran, et navigation au clavier.
Glossaire du chapitre
- Accessibilité (a11y)
- Rendre une application utilisable par tous, y compris avec un lecteur d’écran ou un clavier.
- Lecteur d’écran
- Logiciel qui lit la page à voix haute pour une personne malvoyante.
- ARIA
- Attributs HTML qui donnent du sens aux éléments pour les technologies d’assistance.
- aria-invalid
- Indique à un lecteur d’écran qu’un champ contient une erreur.
- role="alert"
- Demande l’annonce immédiate d’un message dès son apparition.
- Texte alternatif (alt)
- Description d’une image pour les personnes qui ne la voient pas.
Comprendre et essayer (projet demo)
Ce qui sera montré : Dans demo, nous rendons accessible un petit formulaire d'inscription à une lettre d'information (composant newsletter).
Au programme : 38.1 Les bases sémantiques · 38.2 Messages d'erreur et états annoncés · 38.3 Images et contrastes
👥 Cette démonstration est réalisée avec le formateur : regardez, posez vos questions, vous n’avez rien à préparer.
1 exercice
Exercice 38.3.A Un formulaire accessibleEssentiel
Dans newsletter, créez un formulaire réactif avec un champ email (obligatoire, format e-mail). Il doit avoir :
- un
<label for>; - un
aria-invalidqui reflète l'état du champ ; - un message
role="alert"relié pararia-describedby.
Testez ensuite sans souris : Tab, Maj+Tab, Entrée pour envoyer.
Voir la solution
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form" (ngSubmit)="send()">
<label for="email">Adresse email</label>
<input id="email" formControlName="email"
[attr.aria-invalid]="form.controls.email.touched && form.controls.email.invalid"
aria-describedby="email-erreur">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.email.touched && form.controls.email.invalid) {
<small id="email-erreur" role="alert">Adresse email invalide.</small>
}
<button type="submit">S'inscrire</button>
</form>Si un élément reste inaccessible au clavier, c'est presque toujours une <div> à remplacer par un vrai <button> ou <a>.
Étape 36 Des formulaires accessibles
Ce qui est réalisé : Dernière touche de Médiathèque avant les chapitres avancés : nous rendons le formulaire d'ajout de média (celui du bibliothécaire) et le panneau de sélection utilisables au clavier et avec un lecteur d'écran.
Fichiers concernés : src/app/admin/media-form/media-form.html · src/app/selection-panel/selection-panel.html · src/app/contact/contact.ts · src/app/contact/contact.html
👥 Cette étape est réalisée avec le formateur, pas à pas : suivez à l’écran, puis retrouvez le code en fin de chapitre.
Testez-vous : QCM 6 questions✓
QCM · Chapitre 38
Pourquoi préférer un
<button>à une<div>cliquable ?Le HTML sémantique rend l'élément utilisable au clavier et compréhensible.
Que fait
role="alert"sur un message d'erreur ?Le rôle alert déclenche une annonce immédiate.
Quel est le rôle de
altsur une image informative ?Le texte alternatif remplace l'image pour les lecteurs d'écran.
Quel est le bon
altpour une image purement décorative ?Une image décorative ne doit pas être annoncée.
Pourquoi relier un label à son champ avec for et id ?
L'association label/champ donne son nom accessible au champ.
Pourquoi ajouter un
aria-labelsur un bouton ✕ ?L'aria-label donne un nom accessible aux boutons-icônes.
Code de Médiathèque à la fin du chapitre (4 fichiers)
Si vous êtes bloqué, comparez avec ces fichiers : ils donnent l’état de votre projet à la fin de ce chapitre.
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="title">Titre</label>
<input id="title" formControlName="title"
[attr.aria-invalid]="form.controls.title.touched && form.controls.title.invalid"
aria-describedby="title-error">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.title.touched && form.controls.title.hasError('required')) {
<p id="title-error" class="error" role="alert">Le titre est obligatoire.</p>
}
@if (form.controls.title.pending) {
<p class="muted">Vérification...</p>
}
@if (form.controls.title.hasError('taken')) {
<p class="error" role="alert">Ce titre existe déjà.</p>
}
</div>
<div class="field">
<label for="author">Auteur ou réalisateur</label>
<input id="author" formControlName="author"
[attr.aria-invalid]="form.controls.author.touched && form.controls.author.invalid"
aria-describedby="author-error">
@if (form.controls.author.touched && form.controls.author.invalid) {
<p id="author-error" class="error" role="alert">L'auteur est obligatoire.</p>
}
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" formControlName="type">
<option value="livre">Livre</option>
<option value="film">Film</option>
<option value="jeu">Jeu</option>
</select>
</div>
<div class="field">
<label for="genre">Genre</label>
<input id="genre" formControlName="genre"
[attr.aria-invalid]="form.controls.genre.touched && form.controls.genre.invalid"
aria-describedby="genre-error">
@if (form.controls.genre.touched && form.controls.genre.invalid) {
<p id="genre-error" class="error" role="alert">Le genre est obligatoire.</p>
}
</div>
<div class="field">
<label for="year">Année</label>
<input id="year" type="number" formControlName="year"
[attr.aria-invalid]="form.controls.year.touched && form.controls.year.invalid"
aria-describedby="year-error">
@if (form.controls.year.touched && form.controls.year.invalid) {
<p id="year-error" class="error" role="alert">L'année doit être comprise entre 1000 et 2100.</p>
}
</div>
<div class="field">
<label for="loanDays">Durée de prêt (jours)</label>
<input id="loanDays" type="number" formControlName="loanDays"
[attr.aria-invalid]="form.controls.loanDays.touched && form.controls.loanDays.invalid"
aria-describedby="loanDays-error">
@if (form.controls.loanDays.touched && form.controls.loanDays.invalid) {
<p id="loanDays-error" class="error" role="alert">La durée de prêt est d'au moins 1 jour.</p>
}
</div>
<label><input type="checkbox" formControlName="available"> Disponible</label>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (kw of form.controls.keywords.controls; track $index; let i = $index) {
<div class="field">
<input [formControl]="kw" placeholder="Mot-clé" aria-label="Mot-clé">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" type="button" (click)="removeKeyword(i)">Retirer</button>
</div>
}
<button class="btn" type="button" (click)="addKeyword()">+ Ajouter un mot-clé</button>
<p><button class="btn" type="submit">Ajouter</button></p>
</form><aside class="panel">
<h2>Ma sélection</h2>
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} / {{ max }} médias</p>
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of selection.list(); track m.id) {
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<li>{{ m.title }} <button class="btn btn-secondary" type="button" (click)="selection.remove(m.id)"
[attr.aria-label]="'Retirer ' + m.title + ' de ma sélection'">✕</button></li>
<!-- @empty : affiché quand la liste est vide -->
} @empty {
<li class="muted">Aucun média choisi.</li>
}
</ul>
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (selection.count() > 0) {
<p class="muted">Retour prévu le {{ dueDate() | date:'longDate' }}</p>
}
<button class="btn btn-secondary" (click)="selection.clear()">Vider</button>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a class="btn" routerLink="/emprunt">Valider ma sélection</a>
</aside>// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-contact',
imports: [ReactiveFormsModule],
templateUrl: './contact.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Contact {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
message: ['', Validators.required],
});
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log(this.form.getRawValue());
}
}<h1>Suggérer un média</h1>
<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form class="form-card" [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="name">Nom</label>
<input id="name" formControlName="name"
[attr.aria-invalid]="form.controls.name.touched && form.controls.name.invalid"
aria-describedby="name-error">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.name.touched && form.controls.name.invalid) {
<p id="name-error" class="error" role="alert">Le nom est obligatoire.</p>
}
</div>
<div class="field">
<label for="email">Adresse e-mail</label>
<input id="email" type="email" formControlName="email"
[attr.aria-invalid]="form.controls.email.touched && form.controls.email.invalid"
aria-describedby="email-error">
@if (form.controls.email.touched && form.controls.email.invalid) {
<p id="email-error" class="error" role="alert">Adresse e-mail invalide.</p>
}
</div>
<div class="field">
<label for="message">Le média que vous aimeriez voir</label>
<textarea id="message" formControlName="message"
[attr.aria-invalid]="form.controls.message.touched && form.controls.message.invalid"
aria-describedby="message-error"></textarea>
@if (form.controls.message.touched && form.controls.message.invalid) {
<p id="message-error" class="error" role="alert">Le message est obligatoire.</p>
}
</div>
<button class="btn" type="submit">Envoyer</button>
</form>
<!-- {{ }} : affiche la valeur de la classe -->
<p class="muted">Bonjour {{ form.controls.name.value }}</p>Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 12 · 13 / 19
Architecture
Chapitre 39 · Architecture
Le lazy loading : charger à la demande
Charger une partie de l'application seulement quand l'utilisateur en a besoin, pour alléger le premier chargement de la page.
Glossaire du chapitre
- Lazy loading
- Chargement différé : le code d’une page n’est téléchargé qu’au moment où on l’ouvre.
- loadComponent
- Charge un composant à la demande, dans une route.
- loadChildren
- Charge à la demande tout un groupe de routes enfants.
- Chunk
- Fichier JavaScript séparé, produit par le build pour un chargement différé.
1 exercice
Exercice 39.4.A Charger la page « À propos » à la demandeBonus
Dans demo :
- Transformez la route
apropos(créée à l'exercice 16.6.A) enloadComponent. - Ouvrez l'onglet Réseau, rechargez l'accueil, puis cliquez sur « À propos » : repérez le fichier JavaScript chargé à ce moment-là.
- Lancez
ng buildet repérez, dans la liste affichée par le CLI, le morceau correspondant à la pageapropos.
Voir la solution
{
path: 'apropos',
// loadComponent : le composant n’est chargé qu’à l’ouverture de la route
loadComponent: () => import('./apropos/apropos').then((m) => m.Apropos),
},Au premier chargement, le morceau apropos n'apparaît pas dans l'onglet Réseau : il arrive seulement après le clic sur le lien. Après ng build, le CLI le liste séparément du fichier principal, avec sa propre taille.
Testez-vous : QCM 4 questions✓
QCM · Chapitre 39
Quel est l'intérêt du lazy loading ?
Le découpage réduit le poids du premier chargement.
Quelle propriété charge un composant à la demande ?
loadComponent prend une fonction qui importe le composant dynamiquement.
Quelle propriété charge un ensemble de routes enfants à la demande ?
loadChildren importe un fichier de routes au moment où la section est demandée.
Comment vérifier qu'un composant est bien chargé à la demande ?
L'onglet Réseau affiche les fichiers téléchargés au fil de la navigation.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 40 · Architecture
Structurer une application qui grandit
Découper une application par fonctionnalités, charger le code à la demande, personnaliser la stratégie de préchargement, et gérer la configuration des environnements.
3 exercices
Exercice 40.2.A Étape 37 (1/2) : découper l'application en deux fonctionnalitésEssentiel
Vous disposez de Médiathèque avec deux ensembles d'écrans, catalogue et emprunts (la sélection et la validation de l'emprunt), placés au même endroit. Réorganisez-les en deux fonctionnalités indépendantes :
- Créez
features/emprunts/emprunts.routes.ts, qui exporteEMPRUNTS_ROUTESavec une route vide versSelection. - Dans
app.routes.ts, associez/empruntsàEMPRUNTS_ROUTES, sans écrire de composant directement dans la racine. - Vérifiez qu'aucun fichier de
features/cataloguen'importe un fichier defeatures/emprunts.
Votre code à compléter
import { Routes } from '@angular/router';
import { CATALOGUE_ROUTES } from './features/catalogue/catalogue.routes';
// TODO : importez EMPRUNTS_ROUTES depuis ./features/emprunts/emprunts.routes
export const routes: Routes = [
{ path: 'catalogue', children: CATALOGUE_ROUTES },
{ path: '', redirectTo: 'catalogue', pathMatch: 'full' },
// TODO : ajoutez la route 'emprunts' qui utilise EMPRUNTS_ROUTES
];Votre code à compléter
import { Routes } from '@angular/router';
import { Selection } from './selection/selection';
// TODO : exportez EMPRUNTS_ROUTES avec une route vide vers SelectionVoir la solution
import { Routes } from '@angular/router';
import { Selection } from './selection/selection';
export const EMPRUNTS_ROUTES: Routes = [
{ path: '', component: Selection },
];import { Routes } from '@angular/router';
import { CATALOGUE_ROUTES } from './features/catalogue/catalogue.routes';
import { EMPRUNTS_ROUTES } from './features/emprunts/emprunts.routes';
export const routes: Routes = [
{ path: 'catalogue', children: CATALOGUE_ROUTES },
{ path: 'emprunts', children: EMPRUNTS_ROUTES },
{ path: '', redirectTo: 'catalogue', pathMatch: 'full' },
];Le point important est l'absence d'import croisé : chaque fonctionnalité se connaît elle-même, et la racine assemble le tout. Si vous ajoutez une troisième fonctionnalité, vous ne modifiez que app.routes.ts.
Exercice 40.3.A Étape 37 (2/2) : précharger les emprunts, pas le resteEssentiel
Dans la configuration de l'exercice précédent, la fonctionnalité emprunts est très utilisée après le catalogue. Faites en sorte que :
- la route
empruntsportedata: { preload: true }; - la route
cataloguene soit pas préchargée (elle est ouverte en premier, donc inutile de la précharger) ; - le routeur utilise
SelectivePreload.
Votre code à compléter
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
// Ne précharge que les routes marquées avec data: { preload: true }
export class SelectivePreload implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}Votre code à compléter
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'catalogue',
loadChildren: () => import('./features/catalogue/catalogue.routes').then((m) => m.CATALOGUE_ROUTES),
},
{
path: 'emprunts',
// TODO : loadChildren vers EMPRUNTS_ROUTES, plus data: { preload: true }
},
];Votre code à compléter
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
// TODO : ajoutez withPreloading(SelectivePreload) à provideRouter
providers: [provideRouter(routes)],
};Voir la solution
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'catalogue',
// loadChildren : charge à la demande un groupe de routes
loadChildren: () => import('./features/catalogue/catalogue.routes').then((m) => m.CATALOGUE_ROUTES),
},
{
path: 'emprunts',
data: { preload: true },
loadChildren: () => import('./features/emprunts/emprunts.routes').then((m) => m.EMPRUNTS_ROUTES),
},
];// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading } from '@angular/router';
import { routes } from './app.routes';
import { SelectivePreload } from './core/selective-preload';
export const appConfig: ApplicationConfig = {
// provideRouter : active le routeur avec la liste des routes
providers: [provideRouter(routes, withPreloading(SelectivePreload))],
};Vérifiez dans l'onglet Réseau des outils de développement : le fichier de la fonctionnalité emprunts apparaît après le chargement initial, sans clic de votre part.
Testez-vous : QCM 2 questions✓
QCM · Chapitre 40
Dans une architecture par fonctionnalités, quel import est à éviter ?
Une fonctionnalité passe par les services exposés des autres, jamais par leurs composants internes.
Quel composant doit recevoir ses données par
input()pour rester réutilisable ?Un composant présentationnel reçoit ce qu'il affiche et émet des événements.
Exercice 40.5.A Étape 38 : une API selon l'environnementEssentiel
Le service MediaApi (qui reprend le rôle du MediaService du module de base pour l'accès HTTP) doit appeler l'API dont l'adresse est dans config.json, et non une adresse écrite en dur.
- Dans
public/config.json, changezapiUrlpour une autre adresse (par exemplehttp://localhost:3001), puis rechargez l'application : le changement doit suffire, sans recompiler. - Complétez
MediaApi.getAll()pour appeler${store.apiUrl}/medias.
Votre code à compléter
{
"apiUrl": "http://localhost:3000",
"debug": true
}Votre code à compléter
import { Injectable, inject, signal } from '@angular/core';
export interface AppConfig {
apiUrl: string;
debug: boolean;
}
@Injectable({ providedIn: 'root' })
export class AppConfigStore {
private config = signal<AppConfig | null>(null);
// Appelé une fois, au démarrage, avant l'affichage de l'application
async load(): Promise<void> {
const response = await fetch('config.json');
this.config.set((await response.json()) as AppConfig);
}
get apiUrl(): string {
return this.config()?.apiUrl ?? '';
}
}Votre code à compléter
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Media } from '../models/media';
import { AppConfigStore } from './app-config';
@Injectable({ providedIn: 'root' })
export class MediaApi {
private http = inject(HttpClient);
private store = inject(AppConfigStore);
// TODO : appelez `${this.store.apiUrl}/medias` en GET
getAll(): Observable<Media[]> {
throw new Error('à compléter');
}
}Voir la solution
import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Media } from '../models/media';
import { AppConfigStore } from './app-config';
// Déclare un service, injectable dans les composants
@Injectable({ providedIn: 'root' })
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaApi {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
private store = inject(AppConfigStore);
getAll(): Observable<Media[]> {
// Requête HTTP : renvoie un Observable, qui ne part qu’à l’abonnement
return this.http.get<Media[]>(`${this.store.apiUrl}/medias`);
}
}Un point à retenir : AppConfigStore.apiUrl n'est lisible qu'après load(). C'est précisément ce que garantit provideAppInitializer : aucun service ne démarre avant la fin du chargement.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 13 · 14 / 19
RxJS avancé
Chapitre 41 · RxJS avancé
Flux, opérateurs et erreurs
Comprendre la différence entre flux froids et chauds, choisir le bon opérateur de projection, combiner plusieurs flux, et gérer les erreurs et les annulations.
3 exercices
Exercice 41.2.A Étape 39 : une recherche qui ne montre que le dernier résultatEssentiel
Un champ de recherche doit interroger l'API /medias?q=… à chaque frappe, sans appeler le serveur pour chaque lettre. Le résultat affiché doit toujours correspondre au dernier terme saisi.
- Utilisez un
FormControlpour le champ de recherche. - Attendez 300 ms après la dernière frappe (
debounceTime), et ignorez les termes identiques à la précédente (distinctUntilChanged). - Transformez le terme en requête HTTP avec
switchMap. - Exposez le résultat avec
toSignal.
Votre code à compléter
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { Media } from '../models/media';
@Component({
selector: 'app-media-search',
imports: [ReactiveFormsModule],
templateUrl: './media-search.html',
})
export class MediaSearch {
private http = inject(HttpClient);
search = new FormControl('', { nonNullable: true });
// TODO : results est un signal construit à partir de search.valueChanges
// avec debounceTime(300), distinctUntilChanged() et switchMap
results = toSignal(this.http.get<Media[]>('http://localhost:3000/medias'), { initialValue: [] });
}Votre code à compléter
<input [formControl]="search" placeholder="Rechercher un titre, un auteur...">
<ul>
@for (m of results(); track m.id) {
<li>{{ m.title }} ({{ m.author }})</li>
}
</ul>Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-search',
imports: [ReactiveFormsModule],
templateUrl: './media-search.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaSearch {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
search = new FormControl('', { nonNullable: true });
results = toSignal(
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
this.search.valueChanges.pipe(
// debounceTime : attend une pause de saisie avant d’agir
debounceTime(300),
// distinctUntilChanged : ignore une valeur identique à la précédente
distinctUntilChanged(),
// switchMap : annule la requête précédente quand une nouvelle arrive
switchMap((term) => this.http.get<Media[]>(`http://localhost:3000/medias?q=${encodeURIComponent(term)}`)),
),
{ initialValue: [] as Media[] },
);
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<input [formControl]="search" placeholder="Rechercher un titre, un auteur...">
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of results(); track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ m.title }} ({{ m.author }})</li>
}
</ul>Testez en tapant rapidement « dun » puis « dune » : dans l'onglet Réseau, une seule requête part, celle de « dune ». Le terme « dun » a été annulé avant d'être envoyé.
Exercice 41.2.B Étape 40 : une file d'enregistrement qui garde l'ordreEssentiel
Le formulaire de modification d'un média (zone bibliothécaire) envoie chaque modification au serveur avec un PUT. Les modifications doivent arriver dans l'ordre où l'utilisateur les a faites : une annulation ne doit jamais partir avant la modification qu'elle annule.
- Utilisez un
Subjectpour recevoir les modifications. - Envoyez chaque modification avec
concatMap: la suivante ne part qu'une fois la précédente terminée. - Abonnez-vous une seule fois dans le service.
Votre code à compléter
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { Media } from './models/media';
@Injectable({ providedIn: 'root' })
export class MediaSave {
private http = inject(HttpClient);
private changes = new Subject<Media>();
// TODO : abonnez-vous à changes, avec concatMap vers un PUT sur /medias/:id
// et mémorisez l'abonnement pour pouvoir le fermer
save(media: Media): void {
this.changes.next(media);
}
}Voir la solution
import { HttpClient } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Injectable, inject } from '@angular/core';
import { Subject, concatMap } from 'rxjs';
import { Media } from './models/media';
// Déclare un service, injectable dans les composants
@Injectable({ providedIn: 'root' })
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaSave {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
private changes = new Subject<Media>();
constructor() {
this.changes
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
.pipe(concatMap((p) => this.http.put<Media>(`http://localhost:3000/medias/${p.id}`, p)))
// subscribe : s’abonne au flux pour recevoir ses valeurs
.subscribe();
}
save(media: Media): void {
this.changes.next(media);
}
}Si vous remplacez concatMap par mergeMap, les requêtes partent en même temps et leur ordre d'arrivée n'est plus garanti. Ce changement est invisible dans l'interface, et c'est pour cela qu'il faut le comprendre à l'avance.
Testez-vous : QCM 3 questions✓
QCM · Chapitre 41
Quel opérateur faut-il pour afficher une seule fois un écran après avoir reçu deux réponses HTTP ?
forkJoinattend la fin de chaque flux et émet une seule fois.Un filtre change plusieurs fois. Quel opérateur réémet la liste filtrée à chaque changement ?
combineLatestréémet dès qu'un de ses flux change.Quel opérateur ajoute la dernière valeur d'un autre flux sans que ce dernier déclenche un envoi ?
withLatestFromne réagit qu'au flux principal.
Exercice 41.4.A Étape 41 : un chargement qui ne reste jamais bloquéEssentiel
Le service MediaService.getAll() doit, dans cet ordre :
- abandonner une requête qui dépasse 3 secondes ;
- réessayer deux fois en cas d'erreur réseau ou serveur (et non en cas de 404) ;
- si tout échoue, renvoyer une liste vide et afficher « Chargement impossible » dans l'écran.
Votre code à compléter
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, catchError, of, retry, throwError, timeout } from 'rxjs';
import { Media } from './models/media';
const API = 'http://localhost:3000';
@Injectable({ providedIn: 'root' })
export class MediaService {
private http = inject(HttpClient);
// TODO : erreur affichable par l'écran
readonly error = signal<string | null>(null);
getAll(): Observable<Media[]> {
return this.http.get<Media[]>(`${API}/medias`).pipe(
// TODO : timeout(3000)
// TODO : retry(...) : deux essais, seulement si l'erreur n'est pas un 404
// TODO : catchError : mettre le message dans error et renvoyer []
);
}
}Voir la solution
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Injectable, inject, signal } from '@angular/core';
import { Observable, catchError, of, retry, throwError, timeout } from 'rxjs';
import { Media } from './models/media';
const API = 'http://localhost:3000';
// Déclare un service, injectable dans les composants
@Injectable({ providedIn: 'root' })
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaService {
// inject() : demande au conteneur une instance du service
private http = inject(HttpClient);
readonly error = signal<string | null>(null);
getAll(): Observable<Media[]> {
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
return this.http.get<Media[]>(`${API}/medias`).pipe(
timeout(3000),
retry({
count: 2,
delay: (err) => {
// inutile de réessayer une ressource absente
if (err instanceof HttpErrorResponse && err.status === 404) {
return throwError(() => err);
}
return of(null);
},
}),
// catchError : traite l’erreur pour que le flux puisse continuer
catchError(() => {
this.error.set('Chargement impossible');
return of([] as Media[]);
}),
);
}
}L'écran lit error() dans son template et affiche le message. Le service ne décide pas de la présentation : il signale l'erreur, et le composant l'affiche.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 14 · 15 / 19
Services et patterns
Chapitre 42 · Services et patterns
Logique métier et communication
Organiser la logique métier dans des services spécialisés, maîtriser l'injection de dépendances hiérarchique, appliquer quelques patterns utiles, et faire communiquer des composants sans les coupler.
5 exercices
Exercice 42.1.A Étape 42 : une sélection partagée entre deux composantsEssentiel
Le store SelectionStore doit exposer les actions add, remove, clear, et les valeurs count et longestDays. Un composant AddToSelection ajoute un média au clic, et un composant SelectionBadge affiche l'état de la sélection.
- Complétez
SelectionStore.add: si le média est déjà dans la sélection, rien ne change ; sinon la ligne est ajoutée. - Complétez
removeetclear. - Complétez
SelectionBadgepour affichercount()etlongestDays()(« 3 média(s) · retour dans 21 jours »). - Vérifiez que le badge se met à jour quand vous cliquez sur « Ajouter » dans
AddToSelection, et qu'un second clic sur le même média ne change rien.
Votre code à compléter
import { Injectable, computed, signal } from '@angular/core';
export interface SelectionLine {
mediaId: number;
title: string;
days: number;
}
@Injectable({ providedIn: 'root' })
export class SelectionStore {
private readonly lines = signal<SelectionLine[]>([]);
readonly all = this.lines.asReadonly();
readonly count = computed(() => this.lines().length);
readonly longestDays = computed(() =>
this.lines().reduce((max, l) => Math.max(max, l.days), 0),
);
// TODO : add(line) : ajouter la ligne, sauf si le média est déjà présent
add(line: SelectionLine): void {}
// TODO : remove(mediaId) retire la ligne correspondante
remove(mediaId: number): void {}
// TODO : clear() vide la sélection
clear(): void {}
}Votre code à compléter
import { Component, inject } from '@angular/core';
import { SelectionStore } from './selection-store';
@Component({
selector: 'app-selection-badge',
templateUrl: './selection-badge.html',
})
export class SelectionBadge {
readonly store = inject(SelectionStore);
}Votre code à compléter
<!-- TODO : afficher le nombre de médias avec store.count() et le délai avec store.longestDays() -->
<span></span>Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Injectable, computed, signal } from '@angular/core';
// Interface : décrit la forme d’un objet ; elle disparaît à l’exécution
export interface SelectionLine {
mediaId: number;
title: string;
days: number;
}
// Déclare un service, injectable dans les composants
@Injectable({ providedIn: 'root' })
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SelectionStore {
private readonly lines = signal<SelectionLine[]>([]);
readonly all = this.lines.asReadonly();
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly count = computed(() => this.lines().length);
readonly longestDays = computed(() =>
this.lines().reduce((max, l) => Math.max(max, l.days), 0),
);
add(line: SelectionLine): void {
this.lines.update((list) =>
list.some((l) => l.mediaId === line.mediaId) ? list : [...list, line],
);
}
remove(mediaId: number): void {
this.lines.update((list) => list.filter((l) => l.mediaId !== mediaId));
}
clear(): void {
this.lines.set([]);
}
}<!-- {{ }} : affiche la valeur de la classe -->
<span>{{ store.count() }} média(s) · retour dans {{ store.longestDays() }} jours</span>Exercice 42.4.A Étape 43 : changer d'abonnement sans changer le calculEssentiel
La page d'emprunt doit pouvoir passer de la règle standard à la règle premium sans modifier dueIn.
Prérequis : le store SelectionStore de l'exercice 42.1.A.
- Dans
LoanSummary, créez un signalruleinitialisé àstandardRule. - Ajoutez un bouton qui le bascule sur
premiumRule. - Affichez le délai de retour (
dueIn) et le nombre de places restantes (rule().maxItemsmoinsstore.count()). - Vérifiez que les deux valeurs se recalculent, sans aucune modification de
dueIn.
Votre code à compléter
export interface LoanRule {
label: string;
maxItems: number;
dueInDays(baseDays: number): number;
}
export const standardRule: LoanRule = {
label: 'Standard',
maxItems: 5,
dueInDays: (base) => base,
};
export const premiumRule: LoanRule = {
label: 'Premium',
maxItems: 10,
dueInDays: (base) => base + 7,
};Votre code à compléter
import { computed, Signal } from '@angular/core';
import { SelectionLine } from '../selection/selection-store';
import { LoanRule } from './loan-rule';
// Le calcul ne sait pas quelle règle il applique : il reçoit la stratégie
export function dueIn(rule: Signal<LoanRule>, lines: Signal<SelectionLine[]>) {
return computed(() =>
lines().reduce((max, l) => Math.max(max, rule().dueInDays(l.days)), 0),
);
}Votre code à compléter
import { Component, inject, signal } from '@angular/core';
import { SelectionStore } from '../selection/selection-store';
import { LoanRule, premiumRule, standardRule } from './loan-rule';
import { dueIn } from './due-in';
@Component({
selector: 'app-loan-summary',
templateUrl: './loan-summary.html',
})
export class LoanSummary {
protected store = inject(SelectionStore);
// TODO : signal rule initialisé à standardRule
readonly due = dueIn(/* TODO : passer le signal rule */ signal(standardRule), this.store.all);
// TODO : premium() bascule la règle sur premiumRule
}Votre code à compléter
<p>Retour dans {{ due() }} jours</p>
<!-- TODO : places restantes ; bouton « Passer en premium » -->Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, inject, signal } from '@angular/core';
import { SelectionStore } from '../selection/selection-store';
import { LoanRule, premiumRule, standardRule } from './loan-rule';
import { dueIn } from './due-in';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-loan-summary',
templateUrl: './loan-summary.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class LoanSummary {
// inject() : demande au conteneur une instance du service
protected store = inject(SelectionStore);
readonly rule = signal<LoanRule>(standardRule);
readonly due = dueIn(this.rule, this.store.all);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly remaining = computed(() => this.rule().maxItems - this.store.count());
premium(): void {
this.rule.set(premiumRule);
}
}<!-- {{ }} : affiche la valeur de la classe -->
<p>Abonnement : {{ rule().label }}</p>
<p>Retour dans {{ due() }} jours · {{ remaining() }} place(s) restante(s)</p>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="premium()">Passer en premium</button>Ajouter une règle « junior » demain, c'est écrire un nouvel objet qui respecte LoanRule : ni dueIn, ni le composant ne changent. Côté Spring Boot, cette même règle devra exister aussi dans le serveur (une interface LoanPolicy et une implémentation par abonnement) : c'est lui qui la garantit.
Exercice 42.5.A Étape 44 : un filtre de genre qui pilote le catalogueEssentiel
Un composant GenreFilter contient les genres et émet le genre choisi. Le composant MediaList reçoit le genre en entrée et n'affiche que les médias correspondants.
- Dans
GenreFilter, déclarez une sortieselectedde typestring, émise au clic sur un genre. - Dans
MediaList, déclarez une entréegenrede typestring | null, par défautnull. - Dans la page du catalogue, reliez les deux, et filtrez la liste dans un
computed.
Votre code à compléter
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-genre-filter',
template: `
@for (g of genres(); track g) {
<button (click)="choose(g)">{{ g }}</button>
}
`,
})
export class GenreFilter {
genres = input<string[]>([]);
// TODO : sortie selected de type string
choose(g: string): void {
// TODO : émettre g
}
}Votre code à compléter
import { Component, input } from '@angular/core';
import { Media } from '../models/media';
@Component({
selector: 'app-media-list',
templateUrl: './media-list.html',
})
export class MediaList {
medias = input<Media[]>([]);
// TODO : entrée genre de type string | null, par défaut null
}Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input, output } from '@angular/core';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-genre-filter',
template: `
@for (g of genres(); track g) {
<button (click)="choose(g)">{{ g }}</button>
}
`,
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class GenreFilter {
genres = input<string[]>([]);
selected = output<string>();
choose(g: string): void {
this.selected.emit(g);
}
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, input } from '@angular/core';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-list',
templateUrl: './media-list.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaList {
medias = input<Media[]>([]);
genre = input<string | null>(null);
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
import { Media } from './models/media';
import { GenreFilter } from './genre-filter';
import { MediaList } from './media-list/media-list';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
imports: [GenreFilter, MediaList],
selector: 'app-catalogue-page',
templateUrl: './catalogue-page.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class CataloguePage {
readonly medias = signal<Media[]>([]);
readonly genres = ['Science-fiction', 'Aventure', 'Animation'];
readonly genre = signal<string | null>(null);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly visible = computed(() =>
this.genre() ? this.medias().filter((m) => m.genre === this.genre()) : this.medias(),
);
}<app-genre-filter [genres]="genres" (selected)="genre.set($event)" />
<app-media-list [medias]="visible()" [genre]="genre()" />Exercice 42.6.A Un intercepteur d'erreursBonus
Dans demo, écrivez un intercepteur errorInterceptor qui :
- laisse passer la requête avec
next(req); - intercepte les erreurs avec
catchError(importé depuisrxjs) ; - affiche un
console.errorcontenant le statut et l'URL ; - relance l'erreur pour que le composant puisse la gérer.
Enregistrez-le dans app.config.ts, puis changez l'URL de PostService pour une adresse invalide : post-list doit afficher son message d'erreur, et la console Erreur 404 sur ….
Voir la solution
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
// pipe : enchaîne les opérateurs RxJS qui transforment le flux
next(req).pipe(
// catchError : traite l’erreur pour que le flux puisse continuer
catchError((err: HttpErrorResponse) => {
console.error(`Erreur ${err.status} sur ${req.url}`);
return throwError(() => err);
}),
);// provideHttpClient : active HttpClient pour appeler des API
provideHttpClient(withInterceptors([errorInterceptor])),throwError crée un Observable qui émet une erreur. Le composant, qui s'abonne avec un gestionnaire error, reçoit toujours l'erreur, mais le journal est centralisé dans l'intercepteur.
Exercice 42.8.A Un pipe de difficultéEssentiel
Dans demo, créez un pipe difficulty qui transforme un nombre de 1 à 3 en texte : 1 → « Facile », 2 → « Moyen », 3 → « Difficile » (et « Inconnu » sinon). Affichez les trois valeurs dans Pipes.
Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Pipe, PipeTransform } from '@angular/core';
// Déclare un pipe, utilisable dans les templates avec |
@Pipe({ name: 'difficulty' })
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class DifficultyPipe implements PipeTransform {
transform(level: number): string {
switch (level) {
case 1: return 'Facile';
case 2: return 'Moyen';
case 3: return 'Difficile';
default: return 'Inconnu';
}
}
}<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ 1 | difficulty }} / {{ 2 | difficulty }} / {{ 3 | difficulty }}</p>Le pipe reçoit la valeur à gauche du | comme premier argument de transform. Pensez à l'ajouter dans imports du composant.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 15 · 16 / 19
Performances
Chapitre 43 · Performances
Performance : les bases
Rendre l'application plus rapide : limiter les vérifications de changements, charger les parties lourdes au bon moment, et écrire des templates efficaces.
Glossaire du chapitre
- Détection de changements
- Mécanisme qui recherche les changements pour mettre à jour l’affichage.
- OnPush
- Stratégie qui limite la mise à jour d’un composant aux cas où une entrée ou un signal change.
- Zone.js
- Bibliothèque qui prévient Angular après chaque événement asynchrone.
- Zoneless
- Mode sans Zone.js : les signaux suffisent à prévenir Angular des changements.
- @defer
- Bloc dont le contenu n’est chargé qu’à un moment choisi : au défilement, à l’inactivité, au clic.
1 exercice
Exercice 43.4.A Voir le chargement différéEssentiel
Dans demo :
- Créez le composant
notes-recetteavec un paragraphe quelconque. - Dans
app.html, placez-le dans un bloc@defer (on viewport)avec un@placeholder. - Ajoutez au-dessus un
<div style="height: 1500px"></div>pour que le bloc soit hors de l'écran au chargement. - Ouvrez l'onglet Réseau (F12) : le fichier JavaScript du composant n'est téléchargé que lorsque vous faites défiler jusqu'au bloc.
Voir la solution
<div style="height: 1500px"></div>
<!-- @defer : contenu chargé plus tard, selon le déclencheur -->
@defer (on viewport) {
<app-notes-recette />
} @placeholder (minimum 300ms) {
<p>Les notes arrivent...</p>
}Le code du composant est séparé dans un fichier chargé à la demande : la page initiale est plus légère.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 43
Que signifie la stratégie OnPush ?
OnPush limite les vérifications inutiles.
Quel est le rôle de
@defer (on viewport)?Le chargement est retardé jusqu'à l'apparition de la zone.
Que montre le bloc @placeholder dans un
@defer?Le placeholder remplit l'espace avant le chargement.
Pourquoi un track stable est-il important dans un
@for?Un identifiant stable permet à Angular de réutiliser les éléments existants.
Que vaut la stratégie de détection par défaut des composants depuis Angular 22 ?
Depuis Angular 22, les composants sont OnPush par défaut.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Chapitre 44 · Performances
Rendre l'interface rapide et la mesurer
Comprendre comment Angular décide de mettre à jour l'écran, réduire ce travail avec OnPush et les signaux, optimiser les listes et le chargement différé, et mesurer le résultat avec Angular DevTools.
2 exercices
Exercice 44.2.A Étape 45 : une liste de cartes qui ne se redessine pas inutilementEssentiel
Une liste de médias est affichée avec des cartes. Le bouton « Noter » du parent augmente la note d'un média. Toutes les cartes sont aujourd'hui revérifiées à chaque clic, même celles qui ne changent pas.
- Passez le composant
MediaCardenOnPush. - Remplacez l'objet
Mediaau lieu de le modifier, dans le parent, pour que la carte modifiée reçoive une nouvelle référence. - Vérifiez dans Angular DevTools (chapitre 44.5) que seule la carte modifiée est redessinée.
Votre code à compléter
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Media } from './models/media';
@Component({
selector: 'app-media-card',
// TODO : changeDetection OnPush
template: `
<h3>{{ media().title }}</h3>
<p>Note : {{ media().rating ?? '—' }}</p>
`,
})
export class MediaCard {
media = input.required<Media>();
}Votre code à compléter
import { Component, signal } from '@angular/core';
import { MediaCard } from '../media-card';
import { Media } from '../models/media';
@Component({
imports: [MediaCard],
selector: 'app-media-list',
templateUrl: './media-list.html',
})
export class MediaList {
medias = signal<Media[]>([
{ id: 1, title: 'Dune', author: 'Frank Herbert', year: 1965, type: 'livre', genre: 'Science-fiction', available: true, rating: 4.7, loanDays: 21 },
{ id: 2, title: 'Celeste', author: 'Maddy Makes Games', year: 2018, type: 'jeu', genre: 'Plateforme', available: true, rating: 4.4, loanDays: 14 },
]);
// TODO : rate(id) remplace le média par une copie dont la note augmente de 0,1 (maximum 5)
rate(id: number): void {}
}Votre code à compléter
@for (p of medias(); track p.id) {
<app-media-card [media]="p" />
<!-- TODO : bouton « Noter » qui appelle rate(p.id) -->
}Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Media } from './models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h3>{{ media().title }}</h3>
<p>Note : {{ media().rating ?? '—' }}</p>
`,
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaCard {
// input.required : valeur obligatoire, fournie par le parent
media = input.required<Media>();
}// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, signal } from '@angular/core';
import { MediaCard } from '../media-card';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
imports: [MediaCard],
selector: 'app-media-list',
templateUrl: './media-list.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaList {
medias = signal<Media[]>([
{ id: 1, title: 'Dune', author: 'Frank Herbert', year: 1965, type: 'livre', genre: 'Science-fiction', available: true, rating: 4.7, loanDays: 21 },
{ id: 2, title: 'Celeste', author: 'Maddy Makes Games', year: 2018, type: 'jeu', genre: 'Plateforme', available: true, rating: 4.4, loanDays: 14 },
]);
rate(id: number): void {
// on remplace la liste, et seul le média modifié reçoit une nouvelle référence
this.medias.update((list) =>
list.map((m) => (m.id === id ? { ...m, rating: Math.min(5, +((m.rating ?? 4) + 0.1).toFixed(1)) } : m)),
);
}
}<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (p of medias(); track p.id) {
<app-media-card [media]="p" />
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="rate(p.id)">Noter</button>
}Exercice 44.3.A Étape 46 : une liste de mille lignes qui reste fluideEssentiel
Le composant génère 1 000 médias et les affiche avec un champ de filtre (c'est la liste du catalogue de Médiathèque, avec un fonds dix fois plus gros qu'une vraie médiathèque de quartier). Le filtre rend la liste lente à se mettre à jour.
- Ajoutez
track m.idà la boucle@for. - Rendez le filtre réactif avec un
computedplutôt qu'une méthode appelée dans le template. - Ouvrez l'onglet Performance des outils de développement pour comparer le temps de rendu avant et après.
Votre code à compléter
import { Component, signal } from '@angular/core';
import { Media } from '../models/media';
@Component({
selector: 'app-media-list',
templateUrl: './media-list.html',
})
export class MediaList {
readonly filter = signal('');
readonly medias = signal<Media[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
title: `Média ${i + 1}`,
author: 'Auteur',
year: 1950 + (i % 70),
type: 'livre' as const,
genre: 'Roman',
loanDays: 21,
available: i % 3 !== 0,
})),
);
// TODO : visible = computed(...) : médias dont le titre contient filter(), en minuscules
}Votre code à compléter
<input (input)="filter.set($any($event.target).value)" placeholder="Filtrer">
<ul>
<!-- TODO : @for sur visible() avec track m.id -->
</ul>Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, computed, signal } from '@angular/core';
import { Media } from '../models/media';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-media-list',
templateUrl: './media-list.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class MediaList {
// signal : valeur réactive ; on la lit avec () et on la change avec set() ou update()
readonly filter = signal('');
readonly medias = signal<Media[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
title: `Média ${i + 1}`,
author: 'Auteur',
year: 1950 + (i % 70),
type: 'livre' as const,
genre: 'Roman',
loanDays: 21,
available: i % 3 !== 0,
})),
);
// computed : valeur dérivée, recalculée seule quand ses dépendances changent
readonly visible = computed(() => {
const term = this.filter().toLowerCase();
return this.medias().filter((m) => m.title.toLowerCase().includes(term));
});
}<input (input)="filter.set($any($event.target).value)" placeholder="Filtrer">
<ul>
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (m of visible(); track m.id) {
<!-- {{ }} : affiche la valeur de la classe -->
<li>{{ m.title }} ({{ m.year }})</li>
}
</ul>Testez-vous : QCM 3 questions✓
QCM · Chapitre 44
Quel outil permet de voir quels composants Angular a revérifiés après un clic ?
Le Profiler enregistre une interaction et liste les composants traités.
Un composant
OnPushreçoit un objet modifié sans changer de référence. Que se passe-t-il ?OnPushne compare que les références : un objet modifié sur place passe inaperçu.Quand un composant lourd dans une page doit-il être chargé ?
@deferretarde le téléchargement et le rendu du composant.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 16 · 17 / 19
Tests
Chapitre 45 · Tests
Tester composants et services
Tester un composant en simulant des clics, tester un service qui fait des appels HTTP sans serveur réel, avec TestBed et HttpTestingController.
Glossaire du chapitre
- TestBed
- Outil qui monte un composant ou un service dans un environnement de test.
- fixture
- Enveloppe d’un composant de test : elle donne accès à son HTML et à sa classe.
- detectChanges()
- Force Angular à mettre à jour l’affichage du composant de test.
- HttpTestingController
- Fait semblant d’être le serveur : on vérifie les requêtes et on répond à la place de l’API.
- Mock
- Remplacement d’une dépendance réelle par une version simulée.
1 exercice
Exercice 45.3.A Tester la suppressionBonus
Dans post.service.spec.ts, ajoutez un test qui vérifie que remove(3) envoie une requête DELETE sur /posts/3.
Voir la solution
// it : un test, décrit par sa phrase
it('envoie DELETE /posts/3', () => {
// subscribe : s’abonne au flux pour recevoir ses valeurs
service.remove(3).subscribe();
const req = httpMock.expectOne('https://jsonplaceholder.typicode.com/posts/3');
// expect : vérifie que la valeur obtenue est celle attendue
expect(req.request.method).toBe('DELETE');
req.flush({});
});Le subscribe() vide est nécessaire : sans abonnement, la requête n'est jamais envoyée, et expectOne échouerait.
Testez-vous : QCM 5 questions✓
QCM · Chapitre 45
Quel est le rôle de TestBed ?
TestBed fournit le moteur Angular dans un contexte de test.
Pourquoi appeler
fixture.detectChanges()après un clic ?Sans cet appel, le DOM n'est pas mis à jour dans le test.
Que fait
provideHttpClientTesting()?Les requêtes restent en attente et le test fournit les réponses.
Que fait
req.flush(data)?flush joue le rôle de la réponse du serveur.
À quoi sert
httpMock.verify()dans un afterEach ?verify détecte les requêtes oubliées dans le test.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Partie 17 · 18 / 19
Formulaires avancés
Chapitre 46 · Formulaires avancés
Reactive Forms et validations
Construire des formulaires dont la structure varie, écrire des validateurs synchrones et asynchrones, valider plusieurs champs ensemble, et réagir aux changements d'état d'un formulaire.
3 exercices
Exercice 46.2.A Étape 47 : un emprunt à médias multiplesEssentiel
Le formulaire LoanForm (la page d'emprunt de Médiathèque) doit :
- avoir au moins une ligne à l'ouverture ;
- permettre d'ajouter et de retirer des lignes ;
- afficher la durée du prêt, calculée à partir des lignes : la plus longue durée demandée (
days) ; - ne pas pouvoir être envoyé si une ligne est invalide.
Votre code à compléter
import { Component, inject } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-loan-form',
imports: [ReactiveFormsModule],
templateUrl: './loan-form.html',
})
export class LoanForm {
private fb = inject(FormBuilder);
// TODO : form = fb.nonNullable.group({ reader, lines }) ; lines commence vide
form = this.fb.nonNullable.group({
reader: [''],
lines: this.fb.nonNullable.array<FormGroup>([]),
});
get lines(): FormArray<FormGroup> {
return this.form.controls.lines;
}
// TODO : addLine() ajoute une ligne (media requis, days entre 1 et 30)
addLine(): void {}
// TODO : removeLine(index) retire la ligne
removeLine(index: number): void {}
// TODO : longest() renvoie la plus grande valeur de days (0 s'il n'y a aucune ligne)
longest(): number {
return 0;
}
}Votre code à compléter
<form [formGroup]="form">
<!-- TODO : boucle @for sur lines.controls, avec [formGroupName]="i", champs media et days, bouton Retirer -->
<!-- TODO : bouton Ajouter une ligne -->
<!-- TODO : affichez la durée (longest()), et désactivez « Emprunter » si le formulaire est invalide -->
</form>Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-loan-form',
imports: [ReactiveFormsModule],
templateUrl: './loan-form.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class LoanForm {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
reader: [''],
lines: this.fb.nonNullable.array<FormGroup>([]),
});
get lines(): FormArray<FormGroup> {
return this.form.controls.lines;
}
constructor() {
this.addLine();
}
addLine(): void {
this.lines.push(
this.fb.nonNullable.group({
// Validators : validateurs intégrés d’Angular
media: ['', Validators.required],
days: [14, [Validators.required, Validators.min(1), Validators.max(30)]],
}),
);
}
removeLine(index: number): void {
this.lines.removeAt(index);
}
longest(): number {
return this.lines.controls.reduce((max, l) => Math.max(max, l.getRawValue().days), 0);
}
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form">
<!-- @for : répète le bloc pour chaque élément ; track identifie chaque ligne -->
@for (line of lines.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="media" placeholder="Titre du média">
<input formControlName="days" type="number" aria-label="Durée de prêt en jours">
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button type="button" (click)="removeLine(i)">Retirer</button>
</div>
}
<button type="button" (click)="addLine()">Ajouter une ligne</button>
<!-- {{ }} : affiche la valeur de la classe -->
<p>Retour dans {{ longest() }} jours</p>
<button [disabled]="form.invalid">Emprunter</button>
</form>Exercice 46.3.A Étape 48 : une réservation dont la fin suit le débutEssentiel
Le lecteur peut réserver un média actuellement emprunté. Le formulaire de réservation contient un mot pour le bibliothécaire, une date de début et une date de fin.
- Le mot ne doit pas contenir les mots « test » ou « xxx ».
- La date de fin doit être égale ou postérieure à la date de début.
- Le bouton « Réserver » reste désactivé tant qu'une règle n'est pas respectée, et un message explique la règle en cause.
Votre code à compléter
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// TODO : forbiddenWords(words) renvoie un ValidatorFn qui renvoie { forbiddenWord } si un mot est trouvé
export function forbiddenWords(words: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => null;
}Votre code à compléter
import { AbstractControl, ValidationErrors } from '@angular/forms';
// TODO : dateRange(group) renvoie { dateRange: true } si la fin est avant le début
export function dateRange(group: AbstractControl): ValidationErrors | null {
return null;
}Votre code à compléter
<form [formGroup]="form">
<input formControlName="note" placeholder="Un mot pour le bibliothécaire">
<input formControlName="start" type="date">
<input formControlName="end" type="date">
<!-- TODO : message si form.hasError('dateRange') -->
<!-- TODO : bouton « Réserver » désactivé si form.invalid -->
</form>Voir la solution
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenWords(words: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value as string).toLowerCase();
const found = words.find((w) => value.includes(w));
return found ? { forbiddenWord: { word: found } } : null;
};
}import { AbstractControl, ValidationErrors } from '@angular/forms';
export function dateRange(group: AbstractControl): ValidationErrors | null {
const debut = group.get('start')?.value as string;
const fin = group.get('end')?.value as string;
if (!debut || !fin) return null;
return fin >= debut ? null : { dateRange: true };
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form">
<input formControlName="note" placeholder="Un mot pour le bibliothécaire">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.note.hasError('forbiddenWord')) {
<small>Ce message contient un mot interdit.</small>
}
<input formControlName="start" type="date">
<input formControlName="end" type="date">
@if (form.hasError('dateRange')) {
<small>La date de fin doit être après la date de début.</small>
}
<button [disabled]="form.invalid">Réserver</button>
</form>Exercice 46.4.A Étape 49 : un e-mail unique, vérifié sans surcharger l'APIEssentiel
Le champ « email » du formulaire d'inscription d'un lecteur (chapitre 20) doit :
- être vérifié auprès de l'API (
/users?email=…), après une pause de 400 ms, en utilisant le validateur asynchrone de l'exemple ; - n'être validé qu'à la sortie du champ (
updateOn: 'blur') ; - afficher « Email déjà utilisé » quand le serveur renvoie un utilisateur.
Votre code à compléter
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, catchError, debounceTime, map, of, switchMap, take } from 'rxjs';
export function uniqueEmail(): AsyncValidatorFn {
const http = inject(HttpClient);
return (control: AbstractControl) =>
of(control.value).pipe(
debounceTime(400),
switchMap((email: string) =>
http.get<unknown[]>(`http://localhost:3000/users?email=${encodeURIComponent(email)}`),
),
map((users) => (users.length > 0 ? { emailTaken: true } : null)),
catchError(() => of(null)),
take(1),
) as Observable<ValidationErrors | null>;
}Votre code à compléter
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { uniqueEmail } from '../validators/unique-email';
@Component({
selector: 'app-signup-form',
imports: [ReactiveFormsModule],
templateUrl: './signup-form.html',
})
export class SignupForm {
private fb = inject(FormBuilder);
// TODO : champ email : nonNullable, Validators.required et Validators.email,
// validateur asynchrone uniqueEmail(), updateOn 'blur'
form = this.fb.nonNullable.group({
email: [''],
});
}Votre code à compléter
<form [formGroup]="form">
<input formControlName="email" placeholder="Email">
<!-- TODO : message « Email déjà utilisé » si form.controls.email.hasError('emailTaken') -->
<!-- TODO : indication « Vérification… » quand le statut est PENDING -->
</form>Voir la solution
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
// FormBuilder : construit le formulaire à partir de sa description
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { uniqueEmail } from '../validators/unique-email';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-signup-form',
imports: [ReactiveFormsModule],
templateUrl: './signup-form.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class SignupForm {
// inject() : demande au conteneur une instance du service
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
email: this.fb.nonNullable.control(
'',
// Validators : validateurs intégrés d’Angular
{ validators: [Validators.required, Validators.email], asyncValidators: [uniqueEmail()], updateOn: 'blur' },
),
});
}<!-- Relie le champ au formulaire réactif déclaré dans la classe -->
<form [formGroup]="form">
<input formControlName="email" placeholder="Email">
<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (form.controls.email.pending) {
<small>Vérification…</small>
}
@if (form.controls.email.hasError('emailTaken')) {
<small>Email déjà utilisé.</small>
}
</form>Testez-vous : QCM 3 questions✓
QCM · Chapitre 46
Quelle propriété d'un formulaire ignore les champs désactivés ?
valueles ignore ;getRawValue()les garde.Un validateur asynchrone est en cours d'exécution. Quel statut a le contrôle ?
PENDINGsignale qu'une vérification est en cours.Quel réglage évite de valider un champ à chaque frappe ?
updateOn: 'blur'ne valide qu'à la sortie du champ.
Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.
Extras · 19 / 19
Synthèse optionnelle
Chapitre 47 · Synthèse optionnelle
Une petite application complète
Assembler les notions du module (architecture, RxJS, services, performances, formulaires) dans une application de taille réelle.
1 exercice
Exercice 47.1.A Étape 50 : un emprunt de bout en boutEssentiel
Prérequis : les exercices 41.2.A, 45.1.A et 47.2.A.
Implémentez le flux complet : le lecteur recherche un média, l'ajoute à sa sélection, puis valide son emprunt.
- La recherche utilise
MediaSearch(exercice 41.2.A). - Chaque ajout passe par
SelectionStore.add(exercice 42.1.A). - Le formulaire d'emprunt est
LoanForm(exercice 46.2.A). Ajoutez-lui une sortiesubmitted(output<{ reader: string }>()), émise avec la valeur du formulaire quand il est valide. Le composantCheckoutenvoie alors unPOSTsur/loans, avec la liste des identifiants de la sélection et la date de retour. - Après envoi réussi, la sélection est vidée et un message de confirmation s'affiche.
Votre code à compléter
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SelectionStore } from '../selection/selection-store';
import { LoanForm } from './loan-form';
@Component({
selector: 'app-checkout',
imports: [LoanForm],
templateUrl: './checkout.html',
})
export class Checkout {
readonly selection = inject(SelectionStore);
private http = inject(HttpClient);
confirmed = false;
// TODO : submit(form) envoie un POST sur http://localhost:3000/loans
// ({ reader, mediaIds, date, returnDate }), puis vide la sélection et passe confirmed à true
submit(form: { reader: string }): void {}
}Votre code à compléter
@if (confirmed) {
<p>Emprunt enregistré.</p>
} @else {
<p>{{ selection.count() }} média(s) · retour dans {{ selection.longestDays() }} jours</p>
<!-- TODO : intégrer le formulaire LoanForm ici, et le lier à submit() -->
}Voir la solution
D'abord, LoanForm annonce la validation par une sortie :
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { output } from '@angular/core';
// dans la classe LoanForm
submitted = output<{ reader: string }>();
submit(): void {
if (this.form.invalid) return;
this.submitted.emit(this.form.getRawValue());
}Dans le template de LoanForm, la balise devient <form [formGroup]="form" (ngSubmit)="submit()">. Ensuite, Checkout écoute cette sortie :
// Fonctions et décorateurs d’Angular utilisés ci-dessous
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SelectionStore } from '../selection/selection-store';
import { LoanForm } from './loan-form';
// @Component : la fiche d’identité du composant (sélecteur, template, style)
@Component({
selector: 'app-checkout',
imports: [LoanForm],
templateUrl: './checkout.html',
})
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Checkout {
// inject() : demande au conteneur une instance du service
readonly selection = inject(SelectionStore);
private http = inject(HttpClient);
confirmed = false;
submit(form: { reader: string }): void {
const today = new Date();
const back = new Date(today);
back.setDate(back.getDate() + this.selection.longestDays());
const loan = {
reader: form.reader,
mediaIds: this.selection.all().map((l) => l.mediaId),
date: today.toISOString().slice(0, 10),
returnDate: back.toISOString().slice(0, 10),
};
// subscribe : s’abonne au flux pour recevoir ses valeurs
this.http.post('http://localhost:3000/loans', loan).subscribe(() => {
this.selection.clear();
this.confirmed = true;
});
}
}<!-- @if : affiche le bloc seulement si la condition est vraie -->
@if (confirmed) {
<p>Emprunt enregistré.</p>
<!-- @else : affiché quand la condition du @if est fausse -->
} @else {
<!-- {{ }} : affiche la valeur de la classe -->
<p>{{ selection.count() }} média(s) · retour dans {{ selection.longestDays() }} jours</p>
<app-loan-form (submitted)="submit($event)" />
}Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.