Sommaire

Sommaire

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.

  1. 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.

  2. Créer vos projets

    Au chapitre 2, vous créez vous-même votre projet mediatheque avec ng new mediatheque. C’est votre application, que vous construisez pas à pas. Vous créez aussi un projet demo, 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.

  3. 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 ».

  4. 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.

  5. 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

Chapitres 1 à 4

Chapitre 1 · Les fondations

Angular et son écosystème

Objectif

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

  1. Où s'exécute une application Angular ?

  2. Quel est le nom de l'unité de base d'une interface Angular ?

  3. Quelle version d'Angular utilise cette formation ?

  4. Que fait le CLI Angular (commande ng) ?

  5. Quelle syntaxe est aujourd'hui recommandée pour une condition dans un template ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 2 · Les fondations

Installation de l'environnement

Objectif

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.

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.

ÉtapeOùAction
1NavigateurTélécharger et installer Node.js
2PhpStormOuvrir PhpStorm, puis New Project (projet vide)
3PhpStormOuvrir le terminal intégré
4TerminalVérifier Node.js, puis installer l'Angular CLI
5TerminalCréer le projet avec ng new
6TerminalEntrer dans le dossier du projet avec cd
7TerminalLancer le serveur avec ng serve
8NavigateurOuvrir 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.

  1. Ouvrez nodejs.org dans votre navigateur.
  2. 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.
  3. Lancez l'installeur et gardez les options par défaut.
  4. 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.

  1. Ouvrez PhpStorm.
  2. Sur l'écran d'accueil, cliquez sur New Project. (Si un projet est déjà ouvert : menu File → New Project.)
  3. Dans la liste de gauche, choisissez Empty Project.
  4. Dans Location, choisissez un dossier dans vos documents, par exemple formation-angular.
  5. 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.

  1. Menu View → Tool Windows → Terminal.
  2. Ou le raccourci : Alt+F12 (Windows et Linux), ⌥+F12 (macOS).
  3. 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 :

Terminal
node -v
npm -v

Chaque 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.

Terminal
npm install -g @angular/cli@22
cd ~
ng version

L'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 voyezCe que cela signifieQue faire
Angular CLI 22.x et Node 24.xConfiguration attendue pour la formation.Passez au pas 5.
Angular CLI 21.x ou plus ancienUne 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 terminalDeux 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 foundLa 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 :

Terminal
ng new mediatheque

Le 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 new a créé le dossier mediatheque dans 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 :

Terminal
cd mediatheque

Le 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 :

Terminal
ng version

Les 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 :

Terminal
ng serve

Attendez 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.

  1. 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.
  2. 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.
  3. Lancer le serveur depuis l'IDE : ouvrez package.json, cliquez sur l'icône verte à côté de "start" : cela lance ng 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 :

ProjetRôleQuand l'utiliser
mediathequeL'application : la médiathèque que vous construisez de A à ZPartie « Construire Médiathèque » de chaque chapitre
demoUn bac à sable pour essayer les exemples de théorie sans abîmer l'applicationPartie « 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 :

Terminal
ng new demo

Ré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 :

Terminal
cd demo
ng serve --port 4201

mediatheque reste sur http://localhost:4200, demo est sur http://localhost:4201.

Projet Médiathèque Construire Médiathèque (projet mediatheque)

Étape 0 Médiathèque démarre

  1. Vérifiez que le projet mediatheque est créé et que ng serve est lancé.
  2. Ouvrez http://localhost:4200 : vous voyez la page d'accueil par défaut d'Angular.
  3. Ouvrez le fichier src/app/app.ts et remplacez tout son contenu : nous retirons ce dont nous n'avons pas besoin pour l'instant (le routeur reviendra au chapitre 16).
  4. Ouvrez le fichier src/app/app.html et remplacez tout son contenu par <h1>Médiathèque</h1>.
  5. 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 :

src/app/app.ts·TypeScript
// 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
  1. import { Component } from '@angular/core';On importe le décorateur Component, sans lequel Angular ne reconnaît pas la classe comme un composant.
  2. @Component({ ... })Est posé juste avant la classe : il lui donne son identité. selector: 'app-root' est le nom de la balise HTML du composant, templateUrl indique le fichier HTML qui décrit son affichage, styleUrl le fichier CSS qui ne s'applique qu'à lui.
  3. 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 mot export permet aux autres fichiers de l'importer.

Le fichier src/app/app.html doit contenir uniquement :

src/app/app.html·HTML (template Angular)
<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

  1. Quel outil installe les paquets JavaScript d'un projet Angular ?

  2. Quelle commande crée un nouveau projet Angular ?

  3. Quelle est l'adresse habituelle du serveur de développement ?

  4. Que fait la fonctionnalité de « hot reload » ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 3 · Les fondations

Structure d'un projet Angular

Objectif

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.
Projet Médiathèque

É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.css contient les styles globaux, appliqués à toute l'application ;
  • le fichier .css d'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

  1. Dans quel dossier se trouve le code de l'application ?

  2. Pourquoi ne faut-il pas versionner node_modules ?

  3. Quel fichier démarre l'application ?

  4. Quel est le rôle de package.json ?

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.

src/styles.css·CSS
/* ===== 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); }
src/app/app.css·CSS
/* 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; }
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 4 · Les fondations

Les composants : template, style, logique

Objectif

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.
Démo

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.

Projet Médiathèque

É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

  1. Choisissez votre couleur dominante et modifiez --primary, --primary-dark et --primary-soft dans styles.css.
  2. 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).
  3. Remplacez le contenu de media-card.css par le CSS obtenu.
  4. Vérifiez dans le navigateur que les trois cartes s'affichent correctement, puis comparez avec le CSS du cours : lequel préférez-vous ?
  5. 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.

Ma confianceNon évalué
Testez-vous : QCM 5 questions

QCM · Chapitre 4

  1. Quels sont les trois fichiers principaux d'un composant ?

  2. À quoi sert le décorateur @Component ?

  3. Quel nom de classe le CLI produit-il pour le composant media-card dans les projets récents ?

  4. Pourquoi une erreur « is not a known element » apparaît-elle ?

  5. Pour un composant, que signifie « encapsulation des styles » ?

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.

src/app/media-card/media-card.ts·TypeScript
// 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';
  }
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/media-card/media-card.css·CSS
/* 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); }
src/app/app.ts·TypeScript
// 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 {}
src/app/app.html·HTML (template Angular)
<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>
Prompt à copier dans votre assistant·text
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]
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 2 · 2 / 19

TypeScript et réactivité

Chapitres 5 à 9

Chapitre 5 · TypeScript et réactivité

TypeScript : types, fonctions et tableaux

Objectif

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.
Démo

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 :

  1. Un objet recette avec un titre (texte), une duree en minutes (nombre) et une propriété note (nombre, facultatif).
  2. Une fonction formaterNote(note: number): string qui renvoie la note avec une décimale suivie de / 5 (exemple : 4.7 / 5). Utilisez note.toFixed(1).
  3. Une fonction noteAffichee(r) qui renvoie la note formatée si note existe, sinon le texte Pas encore notée.
  4. Affichez dans la console : Crêpes : 4.7 / 5.

Ouvrez la console du navigateur (touche F12) pour lire le résultat.

Voir la solution
src/app/bases-typescript/bases-typescript.ts·TypeScript
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 non if (r.note) ;
  • une note valant 0 est une vraie note, mais if (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).

Ma confianceNon évalué
Testez-vous : QCM 5 questions

QCM · Chapitre 5

  1. Que fait TypeScript avant l'exécution du code ?

  2. Quelle différence entre const et let ?

  3. Que vaut '5' === 5 en TypeScript ?

  4. Quel type écrit-on pour une liste de noms ?

  5. Que renvoie user?.adresse?.ville si adresse n'existe pas ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 6 · TypeScript et réactivité

TypeScript : interfaces, classes et asynchrone

Objectif

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 exemple string | 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.
Démo

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'ObjetsTypescript quand 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 :

  1. Déclarez une interface Recette (titre, duree en minutes, vegetarienne, note facultative) et un type Difficulte ('facile' | 'moyenne' | 'difficile') ajouté à l'interface.
  2. Créez un tableau de trois recettes.
  3. Écrivez rapides(liste: Recette[], dureeMax: number): string[] qui renvoie les titres des recettes de durée inférieure ou égale à dureeMax (filter puis map).
  4. Écrivez async function chargerRecettes(): Promise<Recette[]> qui attend 300 ms puis renvoie le tableau.
  5. Appelez-les et affichez dans la console les titres des recettes de 30 minutes maximum.
Voir la solution
src/app/objets-typescript/objets-typescript.ts·TypeScript
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.

Ma confianceNon évalué
Projet Médiathèque

É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 (et MediaType) qui décrit un média de la médiathèque ;
  • la classe Loan qui 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

  1. À quoi sert une interface TypeScript ?

  2. Que signifie le point d'interrogation dans available?: boolean ?

  3. Quel type choisir pour une valeur dont on ne connaît pas encore le type, en évitant any ?

  4. Que fait public devant un paramètre de constructeur ?

  5. Que renvoie une fonction async ?

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.

src/app/models/media.ts·TypeScript
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;
src/app/data/medias.ts·TypeScript
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;
}
src/app/models/loan.ts·TypeScript
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);
  }
}
src/app/app.ts·TypeScript
// 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());
    });
  }
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 7 · TypeScript et réactivité

Le data binding

Objectif

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.
Démo

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.ts et binding.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 :

  1. Ajoutez un champ de saisie lié par ngModel à une propriété recherche (n'oubliez pas FormsModule).
  2. Affichez sous le champ le texte Vous cherchez : … avec la valeur saisie.
  3. Ajoutez un bouton « Effacer » qui vide la recherche. Il doit être désactivé tant que le champ est vide (utilisez [disabled]).
Voir la solution
src/app/binding/binding.ts·TypeScript
// Classe : regroupe les données (propriétés) et le comportement (méthodes)
export class Binding {
  recherche = '';

  effacer(): void {
    this.recherche = '';
  }
}
src/app/binding/binding.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quelle syntaxe affiche la valeur d'une propriété dans le texte d'un template ?

  2. Que fait [disabled]="stock === 0" ?

  3. Quelle est la bonne syntaxe pour réagir à un clic ?

  4. Que faut-il importer pour utiliser [(ngModel)] ?

  5. Pourquoi une méthode private n'est-elle pas accessible depuis le template ?

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.

src/app/media-card/media-card.ts·TypeScript
// 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';
  }
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/app.css (à la fin du fichier)·CSS
/* Barre de recherche */
.search { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search input { flex: 1; min-width: 0; }
src/app/app.ts·TypeScript
// 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 = '';
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 8 · TypeScript et réactivité

Les signals : l'état réactif

Objectif

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) ou update(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.
Démo

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 :

  1. un signal libres, de type nombre, qui vaut 10 au départ ;
  2. un bouton Réserver qui retire une place, sans jamais descendre sous 0 (utilisez update) ;
  3. un bouton Annuler une réservation qui rend une place, sans dépasser 10 ;
  4. un bouton Remettre à 10 qui utilise set ;
  5. 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
src/app/places/places.ts·TypeScript
// 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);
  }
}
src/app/places/places.html·HTML (template Angular)
<!-- {{ }} : 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).

Ma confianceNon évalué

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 :

  1. un signal prix (nombre, 12.5 au départ) et un signal quantite (nombre, 1 au départ) ;
  2. deux boutons + et − qui modifient quantite (jamais sous 1) ;
  3. un computed total qui vaut prix() * quantite() ;
  4. un second computed livraisonOfferte qui vaut true quand total() atteint 30 ;
  5. 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
src/app/panier/panier.ts·TypeScript
// 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));
  }
}
src/app/panier/panier.html·HTML (template Angular)
<!-- (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.

Ma confianceNon évalué

Exercice 8.5.A Ma liste de coursesEssentiel

Dans demo, construisez un composant courses :

  1. un champ de saisie lié par ngModel à une propriété nom (l'article à acheter) ;
  2. un bouton Ajouter qui ajoute l'article à un signal articles (tableau d'objets { id: number; nom: string; achete: boolean }) ;
  3. chaque article s'affiche dans une liste, avec une case à cocher qui change son état achete ;
  4. un computed restant qui affiche le nombre d'articles pas encore achetés. (La boucle @for du 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
src/app/courses/courses.ts·TypeScript
// 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)),
    );
  }
}
src/app/courses/courses.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué

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 :

  1. une liste de courses courses = signal<string[]>(['Œufs', 'Farine', 'Lait']), affichée avec @for ;
  2. une méthode removeLast() qui retire le dernier élément (utilisez update et slice) ;
  3. un bouton Retirer le dernier, désactivé quand la liste est vide ;
  4. un computed count affiché sous la liste : « 2 articles ».
Voir la solution
src/app/courses/courses.ts·TypeScript
// 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));
  }
}
src/app/courses/courses.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Comment lit-on la valeur d'un signal nommé count ?

  2. Comment ajoute-t-on 1 à un signal count à partir de son ancienne valeur ?

  3. Quelle différence entre signal() et computed() ?

  4. Que fait un effect() ?

  5. Pourquoi utilise-t-on items.update(list => [...list, media]) plutôt que de modifier le tableau sur place ?

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.

src/app/selection-panel/selection-panel.ts·TypeScript
// 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([]);
  }
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
src/app/selection-panel/selection-panel.css·CSS
/* 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; }
src/app/app.ts·TypeScript
// 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 = '';
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

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

Objectif

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 switch en PHP.
Démo

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. Avec const ordre = ['rouge', 'vert', 'orange'], ordre.indexOf('vert') vaut 1, et ordre.indexOf('orange') vaut 2. Elle renvoie -1 si 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 % 3 vaut 1 (7 = 2 × 3 + 1) et 3 % 3 vaut 0. 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 actuelleii + 1(i + 1) % 3Résultat
rouge011vert
vert122orange
orange230rouge

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
src/app/feu/feu.ts·TypeScript
// 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];
  }
}
src/app/feu/feu.html·HTML (template Angular)
<!-- @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.

Ma confianceNon évalué
Projet Médiathèque

Étape 6 Le catalogue complet

Ce qui est réalisé : Dans cette étape, nous :

  • affichons les 12 médias de MEDIAS avec 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

  1. Quelle syntaxe est recommandée pour afficher une liste dans Angular récent ?

  2. À quoi sert la clause track dans un @for ?

  3. Que s'affiche si la liste est vide, dans un bloc @for avec @empty ?

  4. Quel est le rôle de $index dans un @for ?

  5. Quel bloc permet de choisir un affichage parmi plusieurs valeurs ?

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.

src/app/app.css (à la fin du fichier)·CSS
/* 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; }
src/app/app.ts·TypeScript
// 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;
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 3 · 3 / 19

Composants en pratique

Chapitres 10 à 13

Chapitre 10 · Composants en pratique

Les directives d'attribut

Objectif

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.
Démo

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 :

  1. Ajoutez une propriété cuisson (nombre, 0 au départ) et une méthode avancer() qui ajoute 10 sans dépasser 100.
  2. Affichez une <div class="barre"> dont la largeur vaut cuisson en pourcentage ([style.width.%]).
  3. Ajoutez la classe prete à cette barre quand cuisson vaut 100 ([class.prete]).
  4. 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
src/app/recette/recette.ts·TypeScript
// 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);
  }
}
src/app/recette/recette.html·HTML (template Angular)
<!-- [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.

Ma confianceNon évalué

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 :

  1. créez le fichier src/app/recette/surligne.ts avec une classe Surligne (sélecteur [appSurligne]) ;
  2. dans la classe, un signal actif (booléen, false au départ) ;
  3. avec l'option host, mouseenter met actif à true, mouseleave le remet à false, et la classe CSS surligne est présente sur l'élément quand actif() vaut true ('[class.surligne]') ;
  4. ajoutez la règle .surligne { background: #fff3b0; } dans recette.css ;
  5. importez la directive dans Recette et appliquez-la à deux éléments de recette.html.
Voir la solution
src/app/recette/surligne.ts·TypeScript
// 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);
}
src/app/recette/recette.ts·TypeScript
// 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
}
src/app/recette/recette.html·HTML (template Angular)
<!-- {{ }} : affiche la valeur de la classe -->
<h2 appSurligne>{{ titre }}</h2>
<p appSurligne>Note : {{ note }}</p>
src/app/recette/recette.css·CSS
.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.

Ma confianceNon évalué

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 un host, '(input)': 'forcer($event)' transmet à la méthode l'objet événement, qui décrit ce qui vient de se passer. Son type est Event.
  • 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 avec as HTMLInputElement : const champ = event.target as HTMLInputElement;. Ensuite, champ.value est le texte saisi, et on peut aussi lui affecter une nouvelle valeur.

Pour mettre un texte en majuscules, on utilise texte.toUpperCase().

  1. créez src/app/recette/majuscules.ts avec une classe Majuscules (sélecteur [appMajuscules]) ;
  2. avec host, l'événement (input) appelle une méthode forcer($event) ;
  3. dans forcer(event: Event), récupérez le champ avec event.target as HTMLInputElement et remplacez sa valeur par champ.value.toUpperCase() ;
  4. importez la directive dans Recette et posez-la sur <input appMajuscules placeholder="Nom de la recette">.
Voir la solution
src/app/recette/majuscules.ts·TypeScript
// 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();
  }
}
src/app/recette/recette.html·HTML (template Angular)
<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.

Ma confianceNon évalué

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.

  1. créez src/app/recette/chiffres.ts avec une classe Chiffres (sélecteur [appChiffres]) ;
  2. avec host, l'événement (input) appelle une méthode filtrer($event) ;
  3. dans filtrer, récupérez le champ comme à l'exercice C, puis remplacez sa valeur par champ.value.replace(/\D/g, '') (\D désigne tout caractère qui n'est pas un chiffre) ;
  4. importez la directive dans Recette et posez-la sur <input appChiffres placeholder="Nombre de personnes">.
Voir la solution
src/app/recette/chiffres.ts·TypeScript
// 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, '');
  }
}
src/app/recette/recette.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Qu'est-ce qu'une directive d'attribut ?

  2. Quelle écriture ajoute la classe active seulement si isActive est vrai ?

  3. Pourquoi importe-t-on NgClass dans le composant ?

  4. Quel décorateur sert à déclarer une directive ?

  5. Pourquoi le sélecteur de la directive de l'exemple est-il [appHighlight] ?

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.

src/app/highlight.ts·TypeScript
// 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)' : '';
  }
}
src/app/app.ts·TypeScript
// 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;
}
src/app/app.html·HTML (template Angular)
<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>
src/app/app.css·CSS
.card--top { border-color: var(--primary); }
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 11 · Composants en pratique

Communiquer entre composants : input()

Objectif

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.
Démo

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 :

  1. une entrée obligatoire note (nombre) ;
  2. une entrée max (nombre, 5 par défaut, transformée par numberAttribute) ;
  3. une méthode affichage() qui renvoie une chaîne de max caractères : ★ pour chaque point de la note, ☆ pour le reste (la note est un nombre entier compris entre 0 et max) ;
  4. 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
src/app/etoiles/etoiles.ts·TypeScript
// 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());
  }
}
src/app/etoiles/etoiles.html·HTML (template Angular)
<!-- {{ }} : 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Dans quel sens circulent les données entre composants ?

  2. Comment lit-on la valeur d'une entrée title = input('') dans le template ?

  3. Quelle est la différence entre input(0) et input.required<number>() ?

  4. Pourquoi écrit-on title="Dune" sans crochets, mais [media]="m" avec crochets ?

  5. Peut-on modifier directement une entrée à l'intérieur de l'enfant ?

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.

src/app/media-card/media-card.ts·TypeScript
// 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 });
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/media-card/media-card.css·CSS
/* 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); }
src/app/app.ts·TypeScript
// 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;
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 12 · Composants en pratique

Communiquer entre composants : output() et model()

Objectif

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().
Démo

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 :

  1. une entrée obligatoire recette (texte : le nom de la recette) ;
  2. une sortie aime (output<string>()) qui émet le nom de la recette ;
  3. un bouton ♥ J'aime dont le clic appelle une méthode qui fait emit(...) avec le nom de la recette ;
  4. dans App, un signal aimees (signal<string[]>([])) et une méthode onAime(nom) qui ajoute le nom à la liste s'il n'y est pas déjà (includes) ;
  5. dans app.html, affichez deux boutons (Crêpes et Ratatouille) qui écoutent (aime)="onAime($event)", puis une ligne 2 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
src/app/bouton-aime/bouton-aime.ts·TypeScript
// 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());
  }
}
src/app/bouton-aime/bouton-aime.html·HTML (template Angular)
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button (click)="onClick()">♥ J'aime {{ recette() }}</button>
src/app/app.ts·TypeScript
// 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
  }
}
src/app/app.html·HTML (template Angular)
<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
  1. Dans l'enfant, aime = output<string>() déclare la sortie, et emit(this.recette()) envoie le nom de la recette. L'enfant n'a rien à stocker : il annonce seulement « on m'a aimé ».
  2. Dans le parent, (aime)="onAime($event)" écoute cette sortie. $event contient le texte émis, ici le nom de la recette.
  3. 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.
  4. updateFabrique une nouvelle liste ([...liste, nom]), et aimees().join(', ') colle les noms avec une virgule pour l'affichage.
Ma confianceNon évalué

Exercice 12.3.A Noter une recetteEssentiel

Dans demo, créez un composant note-saisie :

  1. un model note (nombre, 0 au départ) ;
  2. cinq boutons 1 à 5 générés avec @for (@for (n of [1, 2, 3, 4, 5]; track n)), qui donnent à note la valeur du bouton cliqué : utilisez note.set(n) dans le (click) ;
  3. une classe choisi sur le bouton dont le numéro est égal à la note ([class.choisi]) ;
  4. dans App, utilisez <app-note-saisie [(note)]="maNote" /> avec un signal maNote et affichez Ma note : ….
Voir la solution
src/app/note-saisie/note-saisie.ts·TypeScript
// 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);
}
src/app/note-saisie/note-saisie.html·HTML (template Angular)
<!-- @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 :

src/app/note-saisie/note-saisie.css·CSS
button {
  margin-right: 0.25rem;
}

.choisi {
  background: gold;
  font-weight: bold;
}
src/app/app.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Projet Médiathèque

É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-bar qui 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

  1. Comment un enfant émet-il un événement vers son parent ?

  2. Comment le parent écoute-t-il une sortie nommée borrow ?

  3. Que permet model() par rapport à input() ?

  4. Quelle syntaxe utilise le parent pour un model nommé query ?

  5. Quand préférer output() à model() ?

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.

src/app/media-card/media-card.ts·TypeScript
// 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
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/app.ts·TypeScript
// 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([]);
  }
}
src/app/selection-panel/selection-panel.ts·TypeScript
// 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);
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
src/app/app.html·HTML (template Angular)
<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>
src/app/search-bar/search-bar.css·CSS
/* Barre de recherche */
.search { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search input { flex: 1; min-width: 0; }
src/app/search-bar/search-bar.ts·TypeScript
// 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('');
  }
}
src/app/search-bar/search-bar.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 13 · Composants en pratique

Le cycle de vie des composants

Objectif

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.
Démo

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 :

  1. un signal secondes (0 au départ) augmenté de 1 toutes les secondes par un setInterval ;
  2. un bouton Pause / Play : une méthode basculer() arrête le minuteur (clearInterval) s'il tourne et le relance s'il est en pause ;
  3. le minuteur doit aussi être arrêté quand le composant est détruit (DestroyRef) ;
  4. dans App, affichez <app-chrono /> dans un @if (visible) avec un bouton qui bascule visible, et vérifiez dans la console (un console.log dans onDestroy) que le nettoyage a lieu.
Voir la solution
src/app/chrono/chrono.ts·TypeScript
// 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);
  }
}
src/app/chrono/chrono.html·HTML (template Angular)
<!-- {{ }} : 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 (ici DestroyRef).
  • signal : la fonction qui crée une valeur réactive.
Le code
  1. secondes et enPause : secondes compte les secondes écoulées, enPause indique si le chrono est arrêté. Ce sont des signals, parce que l'écran doit suivre leurs changements.
  2. idGarde l'identifiant renvoyé par setInterval. Il vaut null quand aucun minuteur ne tourne, ce qui permet de savoir s'il y en a un en cours.
  3. constructor()Démarre le chrono avec demarrer(), puis enregistre le nettoyage avec inject(DestroyRef).onDestroy(...). À la destruction du composant, arreter() coupe le minuteur et la console affiche « Chrono détruit ».
  4. basculer()Appelée par le bouton. En pause, elle relance le chrono (demarrer()) ; sinon, elle l'arrête (arreter()).
  5. demarrer()Commence par if (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).
  6. arreter()Coupe le minuteur avec clearInterval, remet id à null pour que demarrer() puisse le relancer plus tard, et passe enPause à true. Le ?? undefined convertit null en undefined, car clearInterval n'accepte pas null en TypeScript.
  7. demarrer() et arreter() sont private : seul le composant les appelle. Le template n'a accès qu'à basculer().

Dans le template :

  1. {{ secondes() }} affiche le compteur (un signal se lit avec des parenthèses).
  2. (click)="basculer()" appelle la méthode au clic.
  3. {{ 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel hook est le bon endroit pour charger des données au démarrage ?

  2. Pourquoi ne pas faire d'appels réseau dans le constructeur ?

  3. À quoi sert ngOnDestroy ou DestroyRef ?

  4. Que fait inject(DestroyRef).onDestroy(() => clearInterval(id)) ?

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.

src/app/library-clock/library-clock.ts·TypeScript
    // inject() : demande au conteneur une instance du service
    inject(DestroyRef).onDestroy(() => {
      clearInterval(id);
      console.log('Horloge détruite, minuteur arrêté');
    });
src/app/library-clock/library-clock.html·HTML (template Angular)
<!-- {{ }} : 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>
}
src/app/app.ts·TypeScript
  // 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);
  }
src/app/app.html·HTML (template Angular)
      <!-- (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 />
      }
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 4 · 4 / 19

Services et injection

Chapitres 14 à 15

Chapitre 14 · Services et injection

Services et injection de dépendances

Objectif

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.
Démo

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 :

  1. créez le fichier src/app/chef.service.ts (voir la section 14.2 pour le CLI et le renommage) avec la classe ChefService, décorée par @Service() ;
  2. une propriété private chefs: string[] qui contient 'Mamie Jeanne', 'Léo' et 'Papi René' ;
  3. une méthode getAll() qui renvoie la liste des chefs ;
  4. une méthode getParInitiale(initiale: string) qui renvoie le premier chef dont le nom commence par cette lettre, ou undefined s'il n'y en a pas (indice : find et startsWith, comme getById avec find) ;
  5. 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
src/app/chef.service.ts·TypeScript
// 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
  1. @Service()Déclare la classe comme service : une seule instance pour toute l'application, prête à être injectée.
  2. La liste est private : on ne la modifie pas depuis l'extérieur. Les composants passeront par getAll() et getParInitiale().
  3. 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 renvoie undefined, et le type de retour string | undefined le dit explicitement.
Ma confianceNon évalué

Exercice 14.3.A Un service de favorisEssentiel

Dans demo, construisez un service qui garde la liste des recettes favorites :

  1. créez src/app/favoris.service.ts (voir la section 14.2 pour le CLI et le renommage ; dans la section précédente, RecetteService a été écrit de la même façon) avec la classe FavorisService, décorée par @Service() ;
  2. un signal titres (tableau de textes, vide au départ) et un computed nombre qui donne sa taille ;
  3. une méthode ajouter(titre: string) qui ajoute le titre, sauf s'il est déjà dans la liste ou s'il est vide ;
  4. une méthode retirer(titre: string) qui le retire (indice : filter garde les éléments qui respectent une condition ; pour retirer un titre, on garde donc tous ceux qui sont différents de lui, et update range la nouvelle liste dans le signal) ;
  5. créez un composant favoris qui injecte le service avec inject(), affiche nombre(), la liste (avec @for), un champ de saisie lié par ngModel et 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
src/app/favoris.service.ts·TypeScript
// 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));
  }
}
src/app/favoris/favoris.ts·TypeScript
// 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 = '';
  }
}
src/app/favoris/favoris.html·HTML (template Angular)
<!-- 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') :

  1. liste.filter((t) => t !== titre) teste chaque élément t : 'Crêpes' !== 'Tarte' est vrai, on garde ; 'Tarte' !== 'Tarte' est faux, on écarte ; 'Soupe' !== 'Tarte' est vrai, on garde.
  2. filter renvoie le nouveau tableau ['Crêpes', 'Soupe']. L'ancien n'est pas modifié.
  3. 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.

Ma confianceNon évalué

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) :

  1. créez src/app/journal.service.ts avec la classe JournalService (@Service()), un signal lignes (tableau de textes) et une méthode noter(message: string) qui ajoute une ligne ;
  2. dans FavorisService, injectez le journal avec inject(JournalService) ;
  3. appelez noter quand un favori est ajouté (« Ajout : … ») et quand il est retiré (« Retrait : … ») ;
  4. dans le composant favoris, injectez aussi JournalService et affichez ses lignes dans une liste @for.

Aucun fichier ne contient de new : c'est Angular qui fournit chaque service.

Voir la solution
src/app/journal.service.ts·TypeScript
// 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]);
  }
}
src/app/favoris.service.ts·TypeScript
// 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}`);
  }
}
src/app/favoris/favoris.ts·TypeScript
// 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 = '';
  }
}
src/app/favoris/favoris.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué

Exercice 14.5.A Un ou deux compteurs de visites ?Essentiel

Dans demo :

  1. Créez un service CompteurService avec un signal privé total (initialisé à 0) et deux méthodes incrementer() et valeur().
  2. Créez deux composants compteur-a et compteur-b qui injectent ce service et affichent sa valeur avec un bouton +1.
  3. Cliquez sur le bouton de l'un, puis de l'autre : que constatez-vous ?
  4. Ajoutez providers: [CompteurService] au composant compteur-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.
  5. Affichez les deux compteurs dans App et notez les valeurs après chaque clic.
Voir la solution
src/app/compteur.service.ts·TypeScript
// 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);
  }
}
src/app/compteur-a/compteur-a.ts·TypeScript
// 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);
}
src/app/compteur-a/compteur-a.html·HTML (template Angular)
<!-- {{ }} : 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 :

src/app/compteur-b/compteur-b.ts·TypeScript
// 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);
}
src/app/compteur-b/compteur-b.html·HTML (template Angular)
<!-- {{ }} : 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) :

src/app/app.ts·TypeScript
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 :

src/app/app.html·HTML (template Angular)
<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 :

ActionAffichage de AAffichage de B
Au chargementA : 0B : 0
Un clic sur +1 de AA : 1B : 1
Un clic sur +1 de BA : 2B : 2

Étape 4 : compteur-b avec providers. Ajoutez la ligne providers: [CompteurService] dans le décorateur de compteur-b :

src/app/compteur-b/compteur-b.ts·TypeScript
// 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).

ActionAffichage de A (instance racine)Affichage de B (instance locale)
Au chargementA : 0B : 0
Un clic sur +1 de AA : 1B : 0
Un clic sur +1 de AA : 2B : 0
Un clic sur +1 de BA : 2B : 1

Deux situations :

  • sans providers, les deux composants partagent la même instance : cliquer sur l'un fait avancer l'autre ;
  • avec providers: [CompteurService] sur compteur-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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel est le rôle principal d'un service ?

  2. Que fait @Service() par défaut ?

  3. Comment un composant obtient-il un service avec la syntaxe moderne ?

  4. Que se passe-t-il si un composant déclare providers: [CounterService] ?

  5. Peut-on mettre une dépendance dans le constructeur d'un service décoré par @Service() ?

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.

src/app/media.service.ts·TypeScript
// 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),
    );
  }
}
src/app/app.ts·TypeScript
// 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([]);
  }
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 15 · Services et injection

Partager des données entre composants

Objectif

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().
Démo

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 :

  1. Créez un service ThemeService avec un signal privé sombre (booléen, false), exposé en lecture seule, et une méthode basculer().
  2. Créez un composant bouton-theme (un bouton Basculer le thème) et un composant fond qui affiche un <div> portant la classe sombre quand le thème est sombre.
  3. Ajoutez au CSS : .sombre { background: #222; color: white; }. Affichez les deux composants dans App : le bouton doit changer le fond de l'autre composant.
Voir la solution
src/app/theme.service.ts·TypeScript
// 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);
  }
}
src/app/fond/fond.ts·TypeScript
// 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);
}
src/app/bouton-theme/bouton-theme.ts·TypeScript
// 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).

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Pourquoi placer l'état de la sélection dans un service plutôt que dans un composant ?

  2. Que fait this.items.asReadonly() ?

  3. Comment le badge se met-il à jour quand la sélection change ?

  4. Dans add(), pourquoi renvoie-t-on un nouveau tableau plutôt que de modifier l'ancien avec push ?

  5. Quel est le rôle d'un signal privé dans un service ?

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.

src/app/selection.service.ts·TypeScript
// 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([]);
  }
}
src/app/media-card/media-card.ts·TypeScript
// 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());
  }
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/selection-panel/selection-panel.ts·TypeScript
// 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;
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
src/app/selection-badge/selection-badge.ts·TypeScript
// 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);
}
src/app/selection-badge/selection-badge.html·HTML (template Angular)
<!-- {{ }} : affiche la valeur de la classe -->
<span class="badge">Ma sélection : {{ selection.count() }}</span>
src/app/app.ts·TypeScript
// 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()));
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 5 · 5 / 19

Navigation

Chapitres 16 à 18

Chapitre 16 · Navigation

Mise en place du routage

Objectif

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.
Démo

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 :

  1. Créez un composant apropos avec un titre À propos et une phrase de présentation.
  2. Ajoutez la route apropos avant la route joker, et un lien dans le menu avec la classe actif sur la page courante.
  3. Sur la page apropos, ajoutez un bouton Retour à l'accueil qui navigue par le code avec Router (navigateByUrl('/')).
  4. Testez l'adresse /n-importe-quoi : la page introuvable doit s'afficher.

Votre code à compléter

src/app/app.routes.ts·TypeScript
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
src/app/app.routes.ts·TypeScript
export const routes: Routes = [
  { path: '', component: Accueil },
  { path: 'recettes', component: Recettes },
  { path: 'apropos', component: Apropos },
  { path: '**', component: Introuvable },
];
src/app/apropos/apropos.ts·TypeScript
// 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel fichier déclare les routes d'une application Angular ?

  2. Où s'affiche le composant de la route courante ?

  3. Pourquoi utiliser routerLink plutôt que href pour un lien interne ?

  4. Où doit-on placer la route joker ** ?

  5. Comment naviguer vers /catalogue depuis le code d'un composant ?

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.

src/app/app.routes.ts·TypeScript
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 },
];
src/app/home/home.ts·TypeScript
// 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 {}
src/app/home/home.html·HTML (template Angular)
<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>
src/app/catalogue/catalogue.ts·TypeScript
// 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()));
}
src/app/catalogue/catalogue.html·HTML (template Angular)
<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>
src/app/loans/loans.ts·TypeScript
// 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 {}
src/app/loans/loans.html·HTML (template Angular)
<h1>Mes emprunts</h1>
<app-selection-panel />
src/app/contact/contact.ts·TypeScript
// 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 {}
src/app/contact/contact.html·HTML (template Angular)
<h1>Contact</h1>
src/app/not-found/not-found.ts·TypeScript
// 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 {}
src/app/not-found/not-found.html·HTML (template Angular)
<h1>Page introuvable</h1>
<!-- routerLink : lien interne, sans rechargement de la page -->
<a routerLink="/">Retour à l'accueil</a>
src/app/app.ts·TypeScript
// 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 {}
src/app/app.html·HTML (template Angular)
<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>
src/app/app.css·CSS
.site-nav a.actif { color: var(--primary); border-bottom: 2px solid var(--primary); }
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 17 · Navigation

Paramètres de route et routes imbriquées

Objectif

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.
Démo

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 :

  1. Dans recettes.ts, injectez le Router (inject(Router)) et ajoutez une méthode chercher(mot: string) qui appelle this.router.navigate([], { queryParams: { q: mot || null } }). Le tableau vide [] signifie « rester sur la même page », et null retire le paramètre de l'adresse quand le champ est vide.
  2. Dans recettes.html, ajoutez au-dessus de la liste un champ <input> qui affiche la valeur courante de q ([value]="q() ?? ''") et appelle chercher($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)" />.
  3. Testez : en tapant tar, l'adresse devient /recettes?q=tar et 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
src/app/recettes/recettes.ts·TypeScript
// 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 } });
  }
}
src/app/recettes/recettes.html·HTML (template Angular)
<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
  1. #champDonne un nom au champ de saisie dans le template : champ.value est le texte actuellement tapé.
  2. (input)="chercher(champ.value)"À chaque frappe, la méthode chercher() s'exécute.
  3. this.router.navigate([], { queryParams: { q: mot || null } })Navigue vers la page courante ([]) en changeant seulement les paramètres de requête. mot || null donne null quand le champ est vide, et Angular retire alors ?q= de l'adresse.
  4. L'adresse change, donc q change (grâce à withComponentInputBinding()), donc visibles se recalcule, donc la liste se met à jour. Le champ ne filtre pas lui-même : l'adresse est la source de vérité.
Ma confianceNon évalué

Exercice 17.4.A Des recettes à parcourirEssentiel

Dans demo, en repartant de la page recette-detail construite aux sections 17.1 et 16.2 :

  1. Ajoutez un lien ← Retour vers /recettes (importez RouterLink dans RecetteDetail).
  2. Ajoutez deux computed dans recette-detail.ts : precedente et suivante. À partir de la recette affichée, ils retrouvent avec RecetteService.getById() la recette d'identifiant id - 1 et id + 1 (le slug sert à trouver la recette courante, l'id à trouver ses voisines).
  3. 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)).
  4. 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
src/app/recette-detail/recette-detail.ts·TypeScript
// 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;
  });
}
src/app/recette-detail/recette-detail.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Comment déclare-t-on un paramètre id dans une route ?

  2. Quel est le type d'un paramètre de route reçu dans un input() ?

  3. Que fait withComponentInputBinding() ?

  4. Quel est le rôle de <router-outlet /> dans un composant parent d'une route imbriquée ?

  5. À quoi sert pathMatch: 'full' dans une redirection ?

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.

src/app/app.config.ts·TypeScript
// 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()),
  ],
};
src/app/media-detail/media-detail.ts·TypeScript
// 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())));
}
src/app/media-detail/media-detail.html·HTML (template Angular)
<!-- 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>
}
src/app/media-card/media-card.ts·TypeScript
// 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());
  }
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/catalogue/catalogue.ts·TypeScript
// 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()));
}
src/app/home/home.html·HTML (template Angular)
<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>
src/app/admin/admin-layout/admin-layout.ts·TypeScript
// 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 {}
src/app/admin/admin-layout/admin-layout.html·HTML (template Angular)
<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 />
src/app/admin/dashboard/dashboard.html·HTML (template Angular)
<h2>Tableau de bord</h2>
src/app/admin/admin-medias/admin-medias.html·HTML (template Angular)
<h2>Gestion des médias</h2>
src/app/app.routes.ts·TypeScript
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 },
];
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 18 · Navigation

Les guards : protéger les routes

Objectif

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.
Démo

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 :

  1. Dans ChefLayout (src/app/chef/chef-layout/chef-layout.ts), injectez SessionService avec inject() et gardez-le dans une propriété session.
  2. Dans chef-layout.html, affichez « Connecté : Ana » avec session.nom().
  3. Ajoutez un bouton Sortir qui appelle une méthode sortir() : elle appelle session.sortir(), puis navigue vers /entrer.
  4. Vérifiez : ouvrez /entrer, cliquez sur Entrer comme Ana (vous arrivez sur /chef et 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
src/app/chef/chef-layout/chef-layout.ts·TypeScript
// 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');
  }
}
src/app/chef/chef-layout/chef-layout.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Que fait un guard canActivate qui renvoie true ?

  2. Pourquoi renvoie-t-on router.createUrlTree(['/login']) plutôt que de naviguer dans le guard ?

  3. Quelle est la différence entre canMatch et canActivate ?

  4. Un guard suffit-il à sécuriser les données d'une application ?

  5. Quelle est la forme moderne d'un guard ?

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.

src/app/auth/auth.service.ts·TypeScript
// 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);
  }
}
src/app/login/login.ts·TypeScript
// 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');
  }
}
src/app/login/login.html·HTML (template Angular)
<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>
src/app/auth/auth.guard.ts·TypeScript
// 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']);
};
src/app/app.routes.ts·TypeScript
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 },
];
src/app/app.ts·TypeScript
// 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);
}
src/app/app.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 6 · 6 / 19

Formulaires

Chapitres 19 à 21

Chapitre 19 · Formulaires

Les formulaires réactifs : les bases

Objectif

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.
Démo

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 :

  1. trois champs : prenom (texte), niveau (liste déroulante <select> : débutant, confirmé) et newsletter (case à cocher) ;
  2. un aperçu en direct sous le formulaire : Bonjour {{ form.controls.prenom.value }} ;
  3. au clic sur S'inscrire, affichez les valeurs dans la console avec getRawValue(), puis remettez le formulaire à zéro avec form.reset().
Voir la solution
src/app/atelier/atelier.ts·TypeScript
// 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();
  }
}
src/app/atelier/atelier.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Que représente un FormControl ?

  2. Pourquoi utiliser nonNullable: true ?

  3. Quelle directive lie un champ à son FormControl dans le template ?

  4. Quel module doit être importé pour les formulaires réactifs ?

  5. Quel est l'avantage de FormBuilder ?

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.

src/app/contact/contact.ts·TypeScript
// 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());
  }
}
src/app/contact/contact.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 20 · Formulaires

Validation et messages d'erreurs

Objectif

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.
Démo

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 :

  1. un nom obligatoire de 3 à 20 caractères ;
  2. un nombre de personnes entre 1 et 10 ;
  3. une heure d'arrivée (type="time") obligatoire ;
  4. des messages d'erreur affichés seulement après que le champ a été touché ;
  5. le bouton Réserver désactivé tant que le formulaire est invalide.
Voir la solution
src/app/reservation-table/reservation-table.ts·TypeScript
// 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],
  });
}
src/app/reservation-table/reservation-table.html·HTML (template Angular)
<!-- 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quelle directive relie un champ HTML à un contrôle du formulaire réactif ?

  2. Pourquoi afficher l'erreur seulement quand le champ est touched ?

  3. Que fait if (this.form.invalid) return; au début de onSubmit ?

Testez-vous : QCM 5 questions

QCM · Chapitre 20

  1. Que renvoie un validateur quand la valeur est correcte ?

  2. Pourquoi attend-on que le champ soit touched avant d'afficher une erreur ?

  3. Comment vérifier une erreur précise sur un champ ?

  4. Où place-t-on un validateur qui compare deux champs ?

  5. Que fait form.markAllAsTouched() ?

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.

src/app/auth/auth.service.ts·TypeScript
// 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);
  }
}
src/app/login/login.ts·TypeScript
// 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');
  }
}
src/app/login/login.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 21 · Formulaires

Soumission et traitement des données

Objectif

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 id avant un ajout.
POST
Verbe HTTP qui envoie des données pour créer une ressource.
Démo

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 :

  1. Ajoutez le service AvisService ci-dessus et faites-le utiliser par le formulaire avis (méthode submit()).
  2. Créez un composant liste-avis qui injecte le service et affiche la liste des avis (pseudo, note / 5) avec un @for, ou « Aucun avis » avec @empty.
  3. Affichez <app-avis /> et <app-liste-avis /> côte à côte dans App : un avis publié doit apparaître tout de suite dans la liste.
Voir la solution
src/app/liste-avis/liste-avis.ts·TypeScript
// 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);
}
src/app/liste-avis/liste-avis.html·HTML (template Angular)
<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é.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Pourquoi vérifier form.invalid au début de submit() ?

  2. Quelle différence entre form.value et form.getRawValue() ?

  3. Que fait form.reset() ?

  4. Où doit se trouver la logique d'enregistrement des données ?

  5. Pourquoi le template ne peut-il pas lire un service private ?

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.

src/app/media.service.ts·TypeScript
// 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 }]);
  }
}
src/app/admin/media-form/media-form.ts·TypeScript
// 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();
  }
}
src/app/admin/media-form/media-form.html·HTML (template Angular)
<!-- 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>
src/app/admin/admin-medias/admin-medias.ts·TypeScript
// 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);
}
src/app/admin/admin-medias/admin-medias.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 7 · 7 / 19

Données et API

Chapitres 22 à 26

Chapitre 22 · Données et API

HttpClient : appeler une API REST

Objectif

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.
Démo

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 :

  1. Ajoutez provideHttpClient() si ce n'est pas déjà fait.
  2. Dans Posts, ajoutez un signal post = signal<Post | null>(null) et chargez l'article numéro 1 avec fetchOne(1).
  3. Affichez son titre et son texte dans un @if (post(); as p).
Voir la solution
src/app/posts/posts.ts·TypeScript
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));
}
src/app/posts/posts.html·HTML (template Angular)
<!-- @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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel service Angular sert à appeler une API REST ?

  2. Quelle méthode HTTP correspond à la suppression d'une ressource ?

  3. Que se passe-t-il si on écrit this.http.delete(url); sans s'abonner ?

  4. Quelle différence entre PUT et PATCH ?

  5. Que fait le générique dans this.http.get<Media[]>(url) ?

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.

db.json·JSON
{
  "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": []
}
src/app/app.config.ts·TypeScript
// 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(),
  ],
};
src/app/media.service.ts·TypeScript
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]));
  }
}
src/app/app.ts·TypeScript
// 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();
  }
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 23 · Données et API

Introduction à RxJS

Objectif

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, filter ou tap.
async pipe
Pipe du template qui s’abonne et se désabonne tout seul.
Démo

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
src/app/short-posts/short-posts.ts·TypeScript
// 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)),
  );
}
src/app/short-posts/short-posts.html·HTML (template Angular)
<!-- @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>
}
Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quelle est la différence principale entre une Promise et un Observable ?

  2. Que se passe-t-il si on ne s'abonne jamais à un Observable de HttpClient ?

  3. Que fait l'opérateur map dans un pipe ?

  4. Que fait le pipe async ?

  5. Quelle convention de nommage indique un Observable ?

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.

src/app/available-now/available-now.ts·TypeScript
// 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)),
  );
}
src/app/available-now/available-now.html·HTML (template Angular)
<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>
}
src/app/home/home.ts·TypeScript
// 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 {}
src/app/home/home.html·HTML (template Angular)
<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 />
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 24 · Données et API

Flux asynchrones : switchMap, debounce, combinaisons

Objectif

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 : combineLatest réagit à chaque changement, forkJoin attend la fin de tous.
takeUntilDestroyed
Arrête un abonnement automatiquement à la destruction du composant.
Démo

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
src/app/post-search/post-search.ts·TypeScript
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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel opérateur annule la requête précédente quand une nouvelle valeur arrive ?

  2. À quoi sert debounceTime(300) ?

  3. Quelle différence entre combineLatest et forkJoin ?

  4. Que fait takeUntilDestroyed() ?

  5. Pourquoi utiliser distinctUntilChanged dans une 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.

src/app/media.service.ts·TypeScript
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]));
  }
}
src/app/media-search/media-search.ts·TypeScript
// 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
  }
}
src/app/media-search/media-search.html·HTML (template Angular)
<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>
}
src/styles.css·CSS
/* 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; }
src/app/catalogue/catalogue.ts·TypeScript
// 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
}
src/app/catalogue/catalogue.html·HTML (template Angular)
<h1>Catalogue</h1>
<app-media-search [q]="q()" />
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

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

Objectif

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.
Démo

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.

Projet Médiathèque

É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

  1. Que fait catchError ?

  2. Pourquoi utiliser des signals pour loading et error ?

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.

src/app/available-now/available-now.ts·TypeScript
// 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);
      },
    });
  }
}
src/app/available-now/available-now.html·HTML (template Angular)
<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>
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 26 · Données et API

Les ressources modernes : httpResource et les signals

Objectif

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.
Démo

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
src/app/post-viewer/post-viewer.ts·TypeScript
// 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);
  }
}
src/app/post-viewer/post-viewer.html·HTML (template Angular)
<!-- (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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Que donne httpResource au template ?

  2. Que se passe-t-il quand un signal lu dans l'URL d'un httpResource change ?

  3. À quoi sert toSignal ?

  4. Pourquoi indiquer un defaultValue dans httpResource ?

  5. Quand utiliser toObservable ?

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.

src/app/available-now/available-now.ts·TypeScript
// 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));
}
src/app/available-now/available-now.html·HTML (template Angular)
<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>
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 8 · 8 / 19

Qualité et débogage

Chapitres 27

Chapitre 27 · Qualité et débogage

Introduction aux tests avec Vitest

Objectif

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.
Démo

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 :

  1. scaleQuantity(200, 2, 4) vaut 400 ;
  2. scaleQuantity(300, 3, 1) vaut 100 (on passe à moins de personnes) ;
  3. scaleQuantity(100, 4, 4) vaut 100 (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
src/app/utils/recipe-rules.spec.ts·TypeScript
// 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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel est le rôle de describe dans un test ?

  2. Quel matcher faut-il utiliser pour comparer deux tableaux ?

  3. Comment s'appelle le fichier de test d'un service nommé media.service.ts ?

  4. Qu'est-ce qu'une fonction pure ?

  5. Que lance la commande ng test ?

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.

src/app/utils/loan-rules.ts·TypeScript
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;
}
src/app/utils/loan-rules.spec.ts·TypeScript
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));
  });
});
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 9 · 9 / 19

Projet guidé

Chapitres 28

Chapitre 28 · Projet guidé

Projet final : emprunter pour de vrai

Objectif

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.
Projet Médiathèque

É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.

Projet Médiathèque

É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.

Projet Médiathèque

É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

  1. Pourquoi centraliser l'adresse de l'API dans core/config.ts ?

  2. Pourquoi validate() utilise-t-il switchMap entre la création du prêt et les mises à jour ?

  3. Que fait forkJoin dans cette étape ?

  4. Pourquoi le bouton « Supprimer » ne suffit-il pas à protéger les données ?

  5. Quel rôle joue tap dans setAvailable ?

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.

src/app/models/loan-record.ts·TypeScript
// 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
}
src/app/core/config.ts·TypeScript
export const API_URL = 'http://localhost:3000';
src/app/media.service.ts·TypeScript
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)));
  }
}
src/app/loan.service.ts·TypeScript
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);
  }
}
src/app/selection-panel/selection-panel.ts·TypeScript
// 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.'),
      });
  }
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
src/styles.css·CSS
/* bouton d'action destructrice */
.btn-danger { background: var(--bad); border-color: var(--bad); color: #fff; }
.btn-danger:hover { filter: brightness(0.9); }
src/app/admin/admin-medias/admin-medias.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Complément · 10 / 19

Organiser, optimiser, déboguer

Chapitres 29 à 30

Chapitre 29 · Organiser, optimiser, déboguer

Organiser le projet

Objectif

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.
Démo

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 devient MediaList).
  • features/medias/media.service.ts (déjà correct, il change seulement de dossier).
  • features/selection/selection.service.ts (renommé depuis selection.ts).
  • features/selection/selection-badge.ts (renommé depuis SelectionBadge.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.

Ma confianceNon évalué
Testez-vous : QCM 5 questions

QCM · Chapitre 29

  1. Quelle structure est recommandée pour un projet qui grandit ?

  2. Quelle règle distingue un composant d'un service ?

  3. Quelle commande crée un service ?

  4. Quelle convention de nommage pour les fichiers ?

  5. Quel dossier accueille les composants réutilisables dans tout le projet ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 30 · Organiser, optimiser, déboguer

Déboguer une application Angular

Objectif

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é.
Démo

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 :

  1. Créez un composant recipe-card avec une entrée recipe = input.required<string>() et le template <p>{{ recipe() }}</p>.
  2. Dans app.html, utilisez-le avec une faute : <app-recipe-card [recette]="'Crêpes'" />.
  3. Lisez le message d'erreur dans la console. Quelle est la cause ?
  4. 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 :

src/app/app.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quel élément de la console est le plus utile pour trouver l'origine d'une erreur ?

  2. À quoi sert Angular DevTools ?

  3. Que fait un point d'arrêt dans PhpStorm ?

  4. Que signifie NullInjectorError: No provider for X ?

  5. Dans un message d'erreur en cascade, quelle erreur faut-il corriger en premier ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

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

Chapitres 31

Chapitre 31 · Positionnement et rappels

Démarrer le module avancé

Objectif

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

  1. Pour afficher une liste dans un template Angular 22, quelle syntaxe utilisez-vous ?

  2. Quel est le rôle de inject() ?

  3. Quelle différence entre un signal et un Observable ?

  4. Que fait switchMap lorsqu'une nouvelle valeur arrive alors qu'une requête précédente est encore en cours ?

  5. Quel est le rôle de loadComponent dans une route ?

  6. À quoi sert un FormArray ?

  7. Que signifie ChangeDetectionStrategy.OnPush ?

  8. Quel test d'intégration vérifie qu'une requête HTTP part bien ?

  9. Pourquoi utiliser takeUntilDestroyed() ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 11 · 12 / 19

Aller plus loin

Chapitres 32 à 38

Chapitre 32 · Aller plus loin

Composants avancés : projection et références

Objectif

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.
Démo

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
src/app/app.ts·TypeScript
// 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();
}
src/app/app.html·HTML (template Angular)
<!-- (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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Que permet <ng-content /> ?

  2. Quel attribut de <ng-content> choisit une partie précise du contenu ?

  3. À quoi sert <ng-template> ?

  4. Quelle est la version moderne de @ViewChild ?

  5. Pourquoi limiter l'accès direct au DOM ?

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.

src/app/panel/panel.html·HTML (template Angular)
<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>
src/app/panel/panel.ts·TypeScript
// 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 {}
src/app/panel/panel.css·CSS
.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);
}
src/app/home/home.ts·TypeScript
// 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 {}
src/app/home/home.html·HTML (template Angular)
<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>
src/app/available-now/available-now.html·HTML (template Angular)
<!-- (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>
}
src/app/media-search/media-search.ts·TypeScript
// 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();
  }
}
src/app/media-search/media-search.html·HTML (template Angular)
<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>
}
src/app/catalogue/catalogue.ts·TypeScript
// 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();
  }
}
src/app/catalogue/catalogue.html·HTML (template Angular)
<h1>Catalogue</h1>
<!-- (click) : appelle la méthode quand l’utilisateur clique -->
<button class="btn" (click)="goToSearch()">Rechercher</button>
<app-media-search [q]="q()" />
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 33 · Aller plus loin

Les pipes : transformer l'affichage

Objectif

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.
Démo

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.

Projet Médiathèque

É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

  1. Que fait un pipe dans un template ?

  2. Quelle est la syntaxe d'un pipe avec un paramètre ?

  3. Pourquoi enregistrer la locale fr ?

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.

src/app/app.config.ts·TypeScript
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' },
  ],
};
src/app/selection-panel/selection-panel.ts·TypeScript
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.'),
      });
  }
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 34 · Aller plus loin

Formulaires dynamiques : FormArray

Objectif

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.
Démo

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
src/app/recipe-form/recipe-form.ts·TypeScript
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>([]),
});
src/app/recipe-form/recipe-form.html·HTML (template Angular)
<!-- 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>
}
Ma confianceNon évalué
Projet Médiathèque

É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

  1. Quand utilise-t-on un FormArray ?

  2. Que renvoie un validateur asynchrone ?

  3. Pourquoi ajouter une pause (timer(400)) avant la requête ?

  4. Quelle relation entre track $index et un FormArray ici ?

  5. Pourquoi inject() doit-il être appelé au bon moment ?

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.

src/app/models/media.ts·TypeScript
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;
src/app/validators/unique-title.ts·TypeScript
// 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)),
    );
}
src/app/admin/media-form/media-form.ts·TypeScript
// 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();
  }
}
src/app/admin/media-form/media-form.html·HTML (template Angular)
<!-- 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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 35 · Aller plus loin

Préparer la liaison avec Spring Boot

Objectif

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 /api au 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.
Démo

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.

Projet Médiathèque

É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

  1. Que fait le proxy de développement ?

  2. Que signifie CORS ?

  3. Où est-il préférable de définir l'adresse de base de l'API ?

  4. Pourquoi centraliser les appels HTTP dans les services ?

  5. Que se passe-t-il si l'en-tête Authorization est absent d'une requête protégée ?

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.

src/app/core/config.ts·TypeScript
export const API_URL = '/api';
proxy.conf.json·JSON
{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false,
    "pathRewrite": { "^/api": "" }
  }
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

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

Objectif

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.
Projet Médiathèque

É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

  1. Pourquoi le guard selectionNotEmptyGuard redirige-t-il vers le catalogue ?

  2. Pourquoi afficher la confirmation dans la page elle-même, avec un signal ?

  3. Que fait Validators.requiredTrue sur une case à cocher ?

  4. Qui doit garantir qu'un emprunt ne dépasse pas 5 médias ?

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.

src/app/loan/selection.guard.ts·TypeScript
// 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']);
};
src/app/app.routes.ts·TypeScript
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 },
];
src/app/loan/loan-page.ts·TypeScript
// 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."),
      });
  }
}
src/app/loan/loan-page.html·HTML (template Angular)
<!-- @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>
}
src/app/selection-panel/selection-panel.ts·TypeScript
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);
  });
}
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

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

Objectif

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.
Démo

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 btn ré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.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Qu'est-ce qu'un composant de présentation ?

  2. Quel est le rôle d'un composant de page ?

  3. Quel avantage principal de cette séparation ?

  4. Comment la page catalogue réagit-elle à l'emprunt d'un média ?

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.

src/app/media-card/media-card.ts·TypeScript
// 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>();
}
src/app/media-card/media-card.html·HTML (template Angular)
<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>
src/app/media-search/media-search.ts·TypeScript
// 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);
  }
}
src/app/media-search/media-search.html·HTML (template Angular)
<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>
}
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 38 · Aller plus loin

Accessibilité de base

Objectif

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.
Démo

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-invalid qui reflète l'état du champ ;
  • un message role="alert" relié par aria-describedby.

Testez ensuite sans souris : Tab, Maj+Tab, Entrée pour envoyer.

Voir la solution
src/app/newsletter/newsletter.html·HTML (template Angular)
<!-- 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>.

Ma confianceNon évalué
Projet Médiathèque

É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

  1. Pourquoi préférer un <button> à une <div> cliquable ?

  2. Que fait role="alert" sur un message d'erreur ?

  3. Quel est le rôle de alt sur une image informative ?

  4. Quel est le bon alt pour une image purement décorative ?

  5. Pourquoi relier un label à son champ avec for et id ?

  6. Pourquoi ajouter un aria-label sur un bouton ✕ ?

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.

src/app/admin/media-form/media-form.html·HTML (template Angular)
<!-- 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>
src/app/selection-panel/selection-panel.html·HTML (template Angular)
<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>
src/app/contact/contact.ts·TypeScript
// 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());
  }
}
src/app/contact/contact.html·HTML (template Angular)
<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>
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 12 · 13 / 19

Architecture

Chapitres 39 à 40

Chapitre 39 · Architecture

Le lazy loading : charger à la demande

Objectif

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 :

  1. Transformez la route apropos (créée à l'exercice 16.6.A) en loadComponent.
  2. Ouvrez l'onglet Réseau, rechargez l'accueil, puis cliquez sur « À propos » : repérez le fichier JavaScript chargé à ce moment-là.
  3. Lancez ng build et repérez, dans la liste affichée par le CLI, le morceau correspondant à la page apropos.
Voir la solution
src/app/app.routes.ts·TypeScript
{
  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.

Ma confianceNon évalué
Testez-vous : QCM 4 questions

QCM · Chapitre 39

  1. Quel est l'intérêt du lazy loading ?

  2. Quelle propriété charge un composant à la demande ?

  3. Quelle propriété charge un ensemble de routes enfants à la demande ?

  4. Comment vérifier qu'un composant est bien chargé à la demande ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 40 · Architecture

Structurer une application qui grandit

Objectif

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 :

  1. Créez features/emprunts/emprunts.routes.ts, qui exporte EMPRUNTS_ROUTES avec une route vide vers Selection.
  2. Dans app.routes.ts, associez /emprunts à EMPRUNTS_ROUTES, sans écrire de composant directement dans la racine.
  3. Vérifiez qu'aucun fichier de features/catalogue n'importe un fichier de features/emprunts.

Votre code à compléter

src/app/app.routes.ts·TypeScript
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

src/app/features/emprunts/emprunts.routes.ts·TypeScript
import { Routes } from '@angular/router';
import { Selection } from './selection/selection';

// TODO : exportez EMPRUNTS_ROUTES avec une route vide vers Selection
Voir la solution
src/app/features/emprunts/emprunts.routes.ts·TypeScript
import { Routes } from '@angular/router';
import { Selection } from './selection/selection';

export const EMPRUNTS_ROUTES: Routes = [
  { path: '', component: Selection },
];
src/app/app.routes.ts·TypeScript
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.

Ma confianceNon évalué

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 :

  1. la route emprunts porte data: { preload: true } ;
  2. la route catalogue ne soit pas préchargée (elle est ouverte en premier, donc inutile de la précharger) ;
  3. le routeur utilise SelectivePreload.

Votre code à compléter

src/app/core/selective-preload.ts·TypeScript
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

src/app/app.routes.ts·TypeScript
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

src/app/app.config.ts·TypeScript
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
src/app/app.routes.ts·TypeScript
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),
  },
];
src/app/app.config.ts·TypeScript
// 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.

Ma confianceNon évalué
Testez-vous : QCM 2 questions

QCM · Chapitre 40

  1. Dans une architecture par fonctionnalités, quel import est à éviter ?

  2. Quel composant doit recevoir ses données par input() pour rester réutilisable ?

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.

  1. Dans public/config.json, changez apiUrl pour une autre adresse (par exemple http://localhost:3001), puis rechargez l'application : le changement doit suffire, sans recompiler.
  2. Complétez MediaApi.getAll() pour appeler ${store.apiUrl}/medias.

Votre code à compléter

public/config.json·JSON
{
  "apiUrl": "http://localhost:3000",
  "debug": true
}

Votre code à compléter

src/app/core/app-config.ts·TypeScript
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

src/app/core/media-api.ts·TypeScript
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
src/app/core/media-api.ts·TypeScript
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.

Ma confianceNon évalué
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 13 · 14 / 19

RxJS avancé

Chapitres 41

Chapitre 41 · RxJS avancé

Flux, opérateurs et erreurs

Objectif

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.

  1. Utilisez un FormControl pour le champ de recherche.
  2. Attendez 300 ms après la dernière frappe (debounceTime), et ignorez les termes identiques à la précédente (distinctUntilChanged).
  3. Transformez le terme en requête HTTP avec switchMap.
  4. Exposez le résultat avec toSignal.

Votre code à compléter

src/app/media-search/media-search.ts·TypeScript
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

src/app/media-search/media-search.html·HTML (template Angular)
<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
src/app/media-search/media-search.ts·TypeScript
// 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[] },
  );
}
src/app/media-search/media-search.html·HTML (template Angular)
<!-- 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é.

Ma confianceNon évalué

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.

  1. Utilisez un Subject pour recevoir les modifications.
  2. Envoyez chaque modification avec concatMap : la suivante ne part qu'une fois la précédente terminée.
  3. Abonnez-vous une seule fois dans le service.

Votre code à compléter

src/app/media-save.ts·TypeScript
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
src/app/media-save.ts·TypeScript
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.

Ma confianceNon évalué
Testez-vous : QCM 3 questions

QCM · Chapitre 41

  1. Quel opérateur faut-il pour afficher une seule fois un écran après avoir reçu deux réponses HTTP ?

  2. Un filtre change plusieurs fois. Quel opérateur réémet la liste filtrée à chaque changement ?

  3. Quel opérateur ajoute la dernière valeur d'un autre flux sans que ce dernier déclenche un envoi ?

Exercice 41.4.A Étape 41 : un chargement qui ne reste jamais bloquéEssentiel

Le service MediaService.getAll() doit, dans cet ordre :

  1. abandonner une requête qui dépasse 3 secondes ;
  2. réessayer deux fois en cas d'erreur réseau ou serveur (et non en cas de 404) ;
  3. si tout échoue, renvoyer une liste vide et afficher « Chargement impossible » dans l'écran.

Votre code à compléter

src/app/media.service.ts·TypeScript
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
src/app/media.service.ts·TypeScript
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.

Ma confianceNon évalué
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 14 · 15 / 19

Services et patterns

Chapitres 42

Chapitre 42 · Services et patterns

Logique métier et communication

Objectif

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.

  1. Complétez SelectionStore.add : si le média est déjà dans la sélection, rien ne change ; sinon la ligne est ajoutée.
  2. Complétez remove et clear.
  3. Complétez SelectionBadge pour afficher count() et longestDays() (« 3 média(s) · retour dans 21 jours »).
  4. 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

src/app/selection/selection-store.ts·TypeScript
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

src/app/selection/selection-badge.ts·TypeScript
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

src/app/selection/selection-badge.html·HTML (template Angular)
<!-- TODO : afficher le nombre de médias avec store.count() et le délai avec store.longestDays() -->
<span></span>
Voir la solution
src/app/selection/selection-store.ts·TypeScript
// 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([]);
  }
}
src/app/selection/selection-badge.html·HTML (template Angular)
<!-- {{ }} : affiche la valeur de la classe -->
<span>{{ store.count() }} média(s) · retour dans {{ store.longestDays() }} jours</span>
Ma confianceNon évalué

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.

  1. Dans LoanSummary, créez un signal rule initialisé à standardRule.
  2. Ajoutez un bouton qui le bascule sur premiumRule.
  3. Affichez le délai de retour (dueIn) et le nombre de places restantes (rule().maxItems moins store.count()).
  4. Vérifiez que les deux valeurs se recalculent, sans aucune modification de dueIn.

Votre code à compléter

src/app/loan-rules/loan-rule.ts·TypeScript
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

src/app/loan-rules/due-in.ts·TypeScript
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

src/app/loan-rules/loan-summary.ts·TypeScript
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

src/app/loan-rules/loan-summary.html·HTML (template Angular)
<p>Retour dans {{ due() }} jours</p>
<!-- TODO : places restantes ; bouton « Passer en premium » -->
Voir la solution
src/app/loan-rules/loan-summary.ts·TypeScript
// 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);
  }
}
src/app/loan-rules/loan-summary.html·HTML (template Angular)
<!-- {{ }} : 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.

Ma confianceNon évalué

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.

  1. Dans GenreFilter, déclarez une sortie selected de type string, émise au clic sur un genre.
  2. Dans MediaList, déclarez une entrée genre de type string | null, par défaut null.
  3. Dans la page du catalogue, reliez les deux, et filtrez la liste dans un computed.

Votre code à compléter

src/app/genre-filter.ts·TypeScript
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

src/app/media-list/media-list.ts·TypeScript
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
src/app/genre-filter.ts·TypeScript
// 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);
  }
}
src/app/media-list/media-list.ts·TypeScript
// 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);
}
src/app/catalogue-page.ts·TypeScript
// 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(),
  );
}
src/app/catalogue-page.html·HTML (template Angular)
<app-genre-filter [genres]="genres" (selected)="genre.set($event)" />
<app-media-list [medias]="visible()" [genre]="genre()" />
Ma confianceNon évalué

Exercice 42.6.A Un intercepteur d'erreursBonus

Dans demo, écrivez un intercepteur errorInterceptor qui :

  1. laisse passer la requête avec next(req) ;
  2. intercepte les erreurs avec catchError (importé depuis rxjs) ;
  3. affiche un console.error contenant le statut et l'URL ;
  4. 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
src/app/core/error.interceptor.ts·TypeScript
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);
    }),
  );
src/app/app.config.ts·TypeScript
// 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.

Ma confianceNon évalué

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
src/app/shared/difficulty.pipe.ts·TypeScript
// 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';
    }
  }
}
src/app/pipes/pipes.html·HTML (template Angular)
<!-- {{ }} : 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.

Ma confianceNon évalué
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 15 · 16 / 19

Performances

Chapitres 43 à 44

Chapitre 43 · Performances

Performance : les bases

Objectif

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 :

  1. Créez le composant notes-recette avec un paragraphe quelconque.
  2. Dans app.html, placez-le dans un bloc @defer (on viewport) avec un @placeholder.
  3. Ajoutez au-dessus un <div style="height: 1500px"></div> pour que le bloc soit hors de l'écran au chargement.
  4. 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
src/app/app.html·HTML (template Angular)
<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.

Ma confianceNon évalué
Testez-vous : QCM 5 questions

QCM · Chapitre 43

  1. Que signifie la stratégie OnPush ?

  2. Quel est le rôle de @defer (on viewport) ?

  3. Que montre le bloc @placeholder dans un @defer ?

  4. Pourquoi un track stable est-il important dans un @for ?

  5. Que vaut la stratégie de détection par défaut des composants depuis Angular 22 ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Chapitre 44 · Performances

Rendre l'interface rapide et la mesurer

Objectif

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.

  1. Passez le composant MediaCard en OnPush.
  2. Remplacez l'objet Media au lieu de le modifier, dans le parent, pour que la carte modifiée reçoive une nouvelle référence.
  3. Vérifiez dans Angular DevTools (chapitre 44.5) que seule la carte modifiée est redessinée.

Votre code à compléter

src/app/media-card.ts·TypeScript
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

src/app/media-list/media-list.ts·TypeScript
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

src/app/media-list/media-list.html·HTML (template Angular)
@for (p of medias(); track p.id) {
  <app-media-card [media]="p" />
  <!-- TODO : bouton « Noter » qui appelle rate(p.id) -->
}
Voir la solution
src/app/media-card.ts·TypeScript
// 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>();
}
src/app/media-list/media-list.ts·TypeScript
// 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)),
    );
  }
}
src/app/media-list/media-list.html·HTML (template Angular)
<!-- @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>
}
Ma confianceNon évalué

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.

  1. Ajoutez track m.id à la boucle @for.
  2. Rendez le filtre réactif avec un computed plutôt qu'une méthode appelée dans le template.
  3. Ouvrez l'onglet Performance des outils de développement pour comparer le temps de rendu avant et après.

Votre code à compléter

src/app/media-list/media-list.ts·TypeScript
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

src/app/media-list/media-list.html·HTML (template Angular)
<input (input)="filter.set($any($event.target).value)" placeholder="Filtrer">
<ul>
  <!-- TODO : @for sur visible() avec track m.id -->
</ul>
Voir la solution
src/app/media-list/media-list.ts·TypeScript
// 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));
  });
}
src/app/media-list/media-list.html·HTML (template Angular)
<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>
Ma confianceNon évalué
Testez-vous : QCM 3 questions

QCM · Chapitre 44

  1. Quel outil permet de voir quels composants Angular a revérifiés après un clic ?

  2. Un composant OnPush reçoit un objet modifié sans changer de référence. Que se passe-t-il ?

  3. Quand un composant lourd dans une page doit-il être chargé ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 16 · 17 / 19

Tests

Chapitres 45

Chapitre 45 · Tests

Tester composants et services

Objectif

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
src/app/post.service.spec.ts·TypeScript
// 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.

Ma confianceNon évalué
Testez-vous : QCM 5 questions

QCM · Chapitre 45

  1. Quel est le rôle de TestBed ?

  2. Pourquoi appeler fixture.detectChanges() après un clic ?

  3. Que fait provideHttpClientTesting() ?

  4. Que fait req.flush(data) ?

  5. À quoi sert httpMock.verify() dans un afterEach ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Partie 17 · 18 / 19

Formulaires avancés

Chapitres 46

Chapitre 46 · Formulaires avancés

Reactive Forms et validations

Objectif

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 :

  1. avoir au moins une ligne à l'ouverture ;
  2. permettre d'ajouter et de retirer des lignes ;
  3. afficher la durée du prêt, calculée à partir des lignes : la plus longue durée demandée (days) ;
  4. ne pas pouvoir être envoyé si une ligne est invalide.

Votre code à compléter

src/app/loan/loan-form.ts·TypeScript
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

src/app/loan/loan-form.html·HTML (template Angular)
<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
src/app/loan/loan-form.ts·TypeScript
// 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);
  }
}
src/app/loan/loan-form.html·HTML (template Angular)
<!-- 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>
Ma confianceNon évalué

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.

  1. Le mot ne doit pas contenir les mots « test » ou « xxx ».
  2. La date de fin doit être égale ou postérieure à la date de début.
  3. 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

src/app/validators/forbidden-words.ts·TypeScript
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

src/app/validators/date-range.ts·TypeScript
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

src/app/reservation/reservation-form.html·HTML (template Angular)
<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
src/app/validators/forbidden-words.ts·TypeScript
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;
  };
}
src/app/validators/date-range.ts·TypeScript
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 };
}
src/app/reservation/reservation-form.html·HTML (template Angular)
<!-- 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>
Ma confianceNon évalué

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 :

  1. ê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 ;
  2. n'être validé qu'à la sortie du champ (updateOn: 'blur') ;
  3. afficher « Email déjà utilisé » quand le serveur renvoie un utilisateur.

Votre code à compléter

src/app/validators/unique-email.ts·TypeScript
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

src/app/signup/signup-form.ts·TypeScript
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

src/app/signup/signup-form.html·HTML (template Angular)
<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
src/app/signup/signup-form.ts·TypeScript
// 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' },
    ),
  });
}
src/app/signup/signup-form.html·HTML (template Angular)
<!-- 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>
Ma confianceNon évalué
Testez-vous : QCM 3 questions

QCM · Chapitre 46

  1. Quelle propriété d'un formulaire ignore les champs désactivés ?

  2. Un validateur asynchrone est en cours d'exécution. Quel statut a le contrôle ?

  3. Quel réglage évite de valider un champ à chaque frappe ?

Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.

Extras · 19 / 19

Synthèse optionnelle

Chapitres 47

Chapitre 47 · Synthèse optionnelle

Une petite application complète

Objectif

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.

  1. La recherche utilise MediaSearch (exercice 41.2.A).
  2. Chaque ajout passe par SelectionStore.add (exercice 42.1.A).
  3. Le formulaire d'emprunt est LoanForm (exercice 46.2.A). Ajoutez-lui une sortie submitted (output<{ reader: string }>()), émise avec la valeur du formulaire quand il est valide. Le composant Checkout envoie alors un POST sur /loans, avec la liste des identifiants de la sélection et la date de retour.
  4. Après envoi réussi, la sélection est vidée et un message de confirmation s'affiche.

Votre code à compléter

src/app/loan/checkout.ts·TypeScript
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

src/app/loan/checkout.html·HTML (template Angular)
@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 :

src/app/loan/loan-form.ts·TypeScript
// 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 :

src/app/loan/checkout.ts·TypeScript
// 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;
    });
  }
}
src/app/loan/checkout.html·HTML (template Angular)
<!-- @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)" />
}
Ma confianceNon évalué
Mon niveau de confiance sur ce chapitre
Ma confianceNon évalué

Enregistré dans ce navigateur : vous retrouvez votre choix à la prochaine visite.