Skip to content

6. Chapitre 5 - Le client [Angular] de l’application [RdvMedecins]

Ce chapitre détaille, fichier par fichier, le contenu du dossier [rdvmedecins-angular-client] livré avec ce document, avec la présentation adoptée dans tout ce cours : le code source numéroté ligne à ligne, suivi d’un paragraphe « Commentons ce code : ». Chaque dossier du projet contient par ailleurs son propre README.md.

6.1. Rappel de l’architecture

Comme dans le document original, le client suit une architecture en couches, qu’on appellera ici V-C-Services (Vue-Contrôleur-Services) :

Image

Une différence de vocabulaire mérite d’être signalée : en AngularJS 1.x, la Vue (le template HTML) et le Contrôleur (la classe JavaScript associée) étaient deux entités distinctes, reliées par le $scope. En [Angular], elles sont réunies dans une seule entité, le composant (cf. chapitre précédent) - c’est pourquoi, dans ce qui suit, on parlera simplement de « composants » là où le document original distinguait vue et contrôleur.

Comme dans le document original, les composants ne parlent JAMAIS directement HTTP : ce rôle est exclusivement réservé à la couche Services. C’est un principe de conception qui vaut aussi bien pour AngularJS 1.x que pour [Angular] - il n’a pas changé.

6.2. Arborescence du projet

Image

Ce découpage reprend directement la progression du document original (connexion, choix médecin/jour, affichage de l’agenda, réservation), simplement réparti ici en composants [Angular] standalone plutôt qu’en contrôleurs/vues AngularJS 1.x séparés.

Bootstrap est utilisé dans toutes les vues ([node_modules/bootstrap/dist/css/bootstrap.min.css], déclaré dans angular.json) - une évolution directe de Bootstrap 3, déjà utilisé par le document original.

6.3. Fichiers de configuration du projet

Image

6.3.1. package.json

  {
    "name": "rdvmedecins-angular-client",
    "version": "0.0.0",
    "scripts": {
      "ng": "ng",
      "start": "ng serve",
      "build": "ng build",
      "watch": "ng build --watch --configuration development",
      "test": "ng test"
   },
   "private": true,
   "packageManager": "npm@10.9.7",
   "dependencies": {
     "@angular/common": "^22.1.0",
     "@angular/compiler": "^22.1.0",
     "@angular/core": "^22.1.0",
     "@angular/forms": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
     "@ngx-translate/core": "^18.0.0",
     "@ngx-translate/http-loader": "^18.0.0",
     "bootstrap": "^5.3.3",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0"
   },
   "devDependencies": {
     "@angular/build": "^22.1.0",
     "@angular/cli": "^22.1.0",
     "@angular/compiler-cli": "^22.1.0",
     "prettier": "^3.8.1",
     "typescript": "~6.0.0"
   }
 }

Commentons ce code :

  • lignes 4-10 : [scripts: { … }] — les commandes npm run <nom> disponibles. start (ng serve) lance le serveur de développement avec rechargement automatique à chaque modification d’un fichier source - c’est la commande utilisée tout au long de ce document ;
  • lignes 13-25 : [dependencies: { … }] — les paquets nécessaires à l’exécution de l’application dans le navigateur : le cœur d’[Angular] ([@angular/core], [@angular/common], [@angular/compiler], [@angular/forms], [@angular/platform-browser], [@angular/router]), Bootstrap (feuille de style uniquement, cf. angular.json), RxJS (les Observable utilisés par [HttpClient]), et - apportés par ce chapitre - [@ngx-translate/core] et [@ngx-translate/http-loader], la bibliothèque tierce utilisée pour la traduction FR/EN de l’interface (cf. [LanguageService] et app.config.ts plus loin) ;
  • lignes 26-32 : [devDependencies: { … }] — les paquets nécessaires uniquement pendant le développement (CLI, compilateur), jamais embarqués dans le bundle final livré au navigateur : @angular/cli/@angular/build (le CLI et son moteur de compilation), [@angular/compiler-cli] (la compilation ahead-of-time des templates), TypeScript, et Prettier (mise en forme automatique du code, non utilisée dans ce document mais présente dans tout projet [Angular] généré par défaut).

Contrairement au serveur [NestJS] (module commonjs, cf. chapitre 3), un projet [Angular] généré par défaut fonctionne en modules ECMAScript natifs (import/export) - c’est ce que reflète, plus bas, "module": "preserve" dans tsconfig.json.

6.3.2. angular.json

  {
    "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
    "version": 1,
    "cli": { "packageManager": "npm" },
    "newProjectRoot": "projects",
    "projects": {
      "rdvmedecins-angular-client": {
        "projectType": "application",
        "root": "",
       "sourceRoot": "src",
       "prefix": "app",
       "architect": {
         "build": {
           "builder": "@angular/build:application",
           "options": {
             "browser": "src/main.ts",
             "tsConfig": "tsconfig.app.json",
             "assets": [
               { "glob": "**/*", "input": "public" }
             ],
             "styles": [
               "node_modules/bootstrap/dist/css/bootstrap.min.css",
               "src/styles.css"
             ]
           },
           "configurations": { "production": { "...": "..." }, "development": { "...": "..." } },
           "defaultConfiguration": "production"
         },
         "serve": {
           "builder": "@angular/build:dev-server",
           "configurations": { "production": { "...": "..." }, "development": { "...": "..." } },
           "defaultConfiguration": "development"
         }
       }
     }
   }
 }

Commentons ce code (quelques lignes ont été condensées ci-dessus pour la lisibilité - le fichier livré est complet) :

  • ligne 11 : [prefix: app,] — le préfixe imposé au sélecteur de chaque composant du projet (app-login, app-agenda…) - une convention, vérifiée par le compilateur, qui évite les collisions de noms avec d’éventuels composants tiers ;
  • ligne 16 : [browser: src/main.ts,] — le point d’entrée de la compilation - le même fichier que celui expliqué plus loin dans ce chapitre ;
  • lignes 18-20 : [assets: [ { glob: **/*, input: public } ],] — copie tel quel, à la racine de l’application compilée, tout le contenu du dossier public/ - c’est ce mécanisme qui permet à public/i18n/fr.json de se retrouver accessible à l’URL /i18n/fr.json, l’adresse interrogée par TranslateHttpLoader (cf. plus loin dans ce chapitre) ;
  • lignes 21-24 : [styles: [ node_modules/bootstrap/dist/css/bootstrap.min.css, src/styles.css ],] — les feuilles de style globales, concaténées dans cet ordre au moment de la compilation : Bootstrap d’abord (pour que nos propres règles, dans [styles.css], puissent au besoin le surcharger), sans passer par un import CSS @import classique.

Ce fichier n’a pas d’équivalent dans le projet AngularJS 1.x original (les outils de compilation de l’époque - Grunt, Gulp - étaient configurés séparément) ; côté [NestJS] (chapitre 3), c’est nest-cli.json qui en joue un rôle assez proche, en plus simple.

6.3.3. [tsconfig.json]

  {
    "compileOnSave": false,
    "compilerOptions": {
      "strict": true,
      "noImplicitOverride": true,
      "noPropertyAccessFromIndexSignature": true,
      "noImplicitReturns": true,
      "noFallthroughCasesInSwitch": true,
      "skipLibCheck": true,
     "isolatedModules": true,
     "experimentalDecorators": true,
     "importHelpers": true,
     "target": "ES2022",
     "module": "preserve"
   },
   "angularCompilerOptions": {
     "enableI18nLegacyMessageIdFormat": false,
     "strictInjectionParameters": true,
     "strictInputAccessModifiers": true,
     "strictTemplates": true
   },
   "files": [],
   "references": [
     { "path": "./tsconfig.app.json" }
   ]
 }

Commentons ce code :

  • ligne 4 : [strict: true,] — contrairement au serveur [NestJS] (qui n’active que strictNullChecks isolément, cf. chapitre 3), le client active le mode strict complet de TypeScript : chaque variable doit avoir un type déterminable, chaque null/undefined doit être traité explicitement. Un choix plus exigeant, cohérent avec le fait que ce document présente [Angular] comme la référence « bonnes pratiques actuelles » ;
  • ligne 20 : [strictTemplates: true]l’option la plus importante de ce fichier : elle étend la vérification de type de TypeScript jusque dans les templates HTML eux-mêmes ([app.html], agenda.component.html…). Une erreur comme {{ medecin.nomm }} (faute de frappe) ou [peutModifier]="auth.estAdmin" (oubli des parenthèses) est alors signalée à la compilation, exactement comme une erreur dans un fichier .ts - ce qui n’existait pas en AngularJS 1.x (2014), où un tel template compilait toujours, l’erreur ne se révélant qu’à l’exécution (silencieusement, la plupart du temps) ;
  • lignes 22-25 : [files: [], references: [ { path: ./tsconfig.app.json } ]] — ce fichier racine ne compile directement aucun fichier ("files": []) : il délègue à tsconfig.app.json (ci-dessous) via le mécanisme des projets TypeScript référencés (project references), qui permet à un même projet [Angular] de définir, à terme, plusieurs configurations de compilation distinctes (application, tests…) partageant un socle commun.

6.3.4. tsconfig.app.json

  {
    "extends": "./tsconfig.json",
    "compilerOptions": {
      "outDir": "./out-tsc/app",
      "types": []
    },
    "include": [
      "src/**/*.ts"
    ],
   "exclude": [
     "src/**/*.spec.ts"
   ]
 }

Commentons ce code :

  • ligne 2 : [extends: ./tsconfig.json,] — repart du socle commun (mode strict, cible ES2022…) plutôt que de tout redéfinir ;
  • ligne 8 : [src/**/*.ts] — tous les fichiers .ts du projet, y compris (par exemple) language.service.ts, ajouté par ce chapitre ;
  • ligne 11 : [src/**/*.spec.ts] — les fichiers de tests unitaires (absents de ce projet, mais exclus par convention) sont compilés séparément, avec leur propre configuration - non présentée dans ce document.

6.4. Démarrage de l’application

Image

6.4.1. src/index.html

  <!doctype html>
  <html lang="fr">
  <head>
    <meta charset="utf-8">
    <title>RdvMedecins - client Angular</title>
    <base href="/">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="icon" type="image/x-icon" href="favicon.ico">
  </head>
 <body>
   <app-root></app-root>
 </body>
 </html>

Commentons ce code :

  • ligne 2 : [] — la langue par défaut de la page, utile à l’accessibilité et aux moteurs de recherche. Cette page HTML étant statique (chargée par le navigateur AVANT même le démarrage de l’application [Angular]), elle ne peut évidemment pas refléter tout de suite un choix de langue fait plus tard par l’utilisateur ; c’est [LanguageService] (cf. plus loin) qui met à jour cet attribut dynamiquement, dès que l’utilisateur bascule vers l’anglais ;
  • ligne 11 : [] — le sélecteur du composant racine ([App], cf. app.ts : selector: 'app-root') : c’est l’unique élément que cette page contient au départ, et c’est [Angular] qui le remplit entièrement une fois démarré. Exactement le même principe qu’en AngularJS 1.x (2014), qui posait ng-app="rdvmedecinsApp" sur la balise <html> du fichier [app.html] du projet original - une page HTML quasi vide, entièrement pilotée par le framework JavaScript.

6.4.2. [src/main.ts]

1
2
3
4
5
  import { bootstrapApplication } from '@angular/platform-browser';
  import { appConfig } from './app/app.config';
  import { App } from './app/app';

  bootstrapApplication(App, appConfig).catch((err) => console.error(err));

Commentons ce code :

  • ligne 5 : [bootstrapApplication(App, appConfig).catch((err) => console.error(err));] — démarre l’application en lui indiquant le composant racine ([App]) et sa configuration (appConfig, cf. app.config.ts plus loin) ; bootstrapApplication est la fonction de démarrage des applications [Angular] standalone (sans NgModule), qui remplace le platformBrowserDynamic().bootstrapModule([AppModule]) des versions plus anciennes d’Angular. .catch(...) capture une éventuelle erreur survenue pendant le démarrage lui-même (avant que la moindre ligne de l’application ne s’exécute) - un cas suffisamment rare et grave pour mériter un traitement séparé de la gestion d’erreurs habituelle de l’application.

Il n’y a qu’un seul point d’entrée pour toute l’application : c’est la définition même d’une application web à page unique (Single Page Application), déjà présentée dans le document original.

6.4.3. [src/styles.css]

  body {
    background-color: #f5f7fa;
  }

  .creneau-libre {
    cursor: pointer;
  }

  .creneau-libre:hover {
   background-color: #e9f7ef;
 }

Commentons ce code :

  • ligne 1 : [body { background-color: #f5f7fa; }] — un gris très clair plutôt que le blanc pur par défaut, pour que les cartes Bootstrap (fond blanc, .card) se détachent légèrement du fond de la page ;
  • lignes 5-10 : [.creneau-libre] — appliquée aux lignes du tableau d’agenda représentant un créneau libre (cf. agenda.component.html, [class.creneau-libre]="creneauAgenda.rv === null") : le curseur devient une main (cursor: pointer) et le fond se teinte légèrement en vert au survol, pour suggérer que la ligne est cliquable - avant même de cliquer dessus.

Ce fichier contient les styles globaux, par opposition aux styles « locaux » définis dans le fichier .css de chaque composant (agenda.component.css…), qui ne s’appliquent qu’au template de ce composant précis. src/app/app.css, le fichier de styles du composant racine, est resté vide : Bootstrap et [styles.css] suffisent à tout ce que ce document met en œuvre.

6.5. La couche core/models

Image

6.5.1. src/app/core/models/rdv.models.ts

  export interface Reponse<T> {
    status: number;
    data: T | null;
  }

  export interface Medecin {
    id: number;
    titre: string;
    nom: string;
   prenom: string;
 }

 export interface Client {
   id: number;
   titre: string;
   nom: string;
   prenom: string;
 }

 export interface CreneauJson {
   id: number;
   hDebut: number;
   mDebut: number;
   hFin: number;
   mFin: number;
 }

 export interface RvJson {
   id: number;
   jour: string;
   client: Client | null;
   creneau: CreneauJson | null;
 }

 export interface CreneauAgenda {
   creneau: CreneauJson;
   rv: RvJson | null;
 }

 export interface AgendaMedecinJour {
   medecin: Medecin;
   jour: string;
   creneaux: CreneauAgenda[];
 }

 export type Role = 'ADMIN' | 'USER';

 export interface LoginResultat {
   accessToken: string;
   login: string;
   nom: string;
   role: Role;
 }

Commentons ce code :

  • lignes 1-4 : [export interface Reponse { status: number; data: T | null; }] — correspond terme à terme à la classe [Reponse]<T> du serveur (cf. chapitre 3) : toute réponse JSON a cette forme ;
  • lignes 6-18 : [Medecin / Client] — deux interfaces identiques dans leur forme, correspondant aux entités [TypeORM] du même nom (chapitre 3). Rappel : [Client] désigne ici un patient, pas un « client HTTP » ;
  • lignes 20-33 : [CreneauJson / RvJson] — les formes « légères » produites côté serveur par getMapForCreneau/getMapForRv (chapitre 3, static.helper.ts) ;
  • lignes 40-44 : [export interface AgendaMedecinJour { … }] — correspond à la réponse de GET /getAgendaMedecinJour/:idMedecin/:jour ;
  • ligne 46 : [export type Role = ADMIN | USER;] — copie conforme du type [Role] du serveur (src/entities/user.entity.ts) ;
  • lignes 48-53 : [export interface LoginResultat { … }] — la forme des données renvoyées par POST /login (cf. [AuthService] ci-dessous), copie conforme de l’interface serveur du même nom (auth/login-resultat.model.ts).

En AngularJS 1.x (2014), le JavaScript pur ne permettait pas de décrire ainsi la forme attendue des données : une réponse du serveur était manipulée comme un objet quelconque, sans qu’aucune vérification ne soit faite avant l’exécution. Avec TypeScript, une faute de frappe sur un nom de champ est désormais signalée avant même d’exécuter le programme, dès la compilation.

6.6. La couche core/services

Image

6.6.1. src/app/core/services/settings.service.ts

  import { Injectable, signal } from '@angular/core';

  @Injectable({ providedIn: 'root' })
  export class SettingsService {
    readonly apiBaseUrl = signal('http://localhost:8080');

    setApiBaseUrl(url: string): void {
      this.apiBaseUrl.set(url);
    }
 }

Commentons ce code :

  • ligne 3 : [@Injectable({ providedIn: 'root' })] — déclare un service comme singleton global de l’application : [Angular] en crée une seule instance, automatiquement, la première fois qu’un composant en a besoin - l’équivalent moderne d’un service AngularJS 1.x déclaré avec .service() ou .factory() ;
  • ligne 5 : [readonly apiBaseUrl = signal(http://localhost:8080);] — signal(...) crée une valeur réactive : quiconque lit apiBaseUrl() dans un template [Angular] sera automatiquement notifié si sa valeur change - le mécanisme central de réactivité de l’[Angular] moderne, qui remplace la surveillance ($watch/$scope.$apply) d’AngularJS 1.x ;
  • lignes 7-9 : [setApiBaseUrl(url: string): void { this.apiBaseUrl.set(url); }] — repris du champ « URL du serveur » de la vue d’accueil du document original, qui permettait déjà à l’utilisateur de la modifier depuis l’interface (utile si le serveur tourne sur un autre port).

6.6.2. src/app/core/services/rdv.service.ts

  import { Injectable, inject } from '@angular/core';
  import { HttpClient } from '@angular/common/http';
  import { Observable, map } from 'rxjs';
  import { SettingsService } from './settings.service';
  import { AgendaMedecinJour, Client, Medecin, Reponse, RvJson } from '../models/rdv.models';

  @Injectable({ providedIn: 'root' })
  export class RdvService {
    private readonly http = inject(HttpClient);
   private readonly settings = inject(SettingsService);

   getAllMedecins(): Observable<Medecin[]> {
     return this.http
       .get<Reponse<Medecin[]>>(`${this.settings.apiBaseUrl()}/getAllMedecins`)
       .pipe(map((reponse) => this.extraire(reponse)));
   }

   getAllClients(): Observable<Client[]> {
     return this.http
       .get<Reponse<Client[]>>(`${this.settings.apiBaseUrl()}/getAllClients`)
       .pipe(map((reponse) => this.extraire(reponse)));
   }

   getAgendaMedecinJour(idMedecin: number, jour: string): Observable<AgendaMedecinJour> {
     return this.http
       .get<Reponse<AgendaMedecinJour>>(
         `${this.settings.apiBaseUrl()}/getAgendaMedecinJour/${idMedecin}/${jour}`,
       )
       .pipe(map((reponse) => this.extraire(reponse)));
   }

   ajouterRv(jour: string, idClient: number, idCreneau: number): Observable<RvJson> {
     return this.http
       .post<Reponse<RvJson>>(`${this.settings.apiBaseUrl()}/ajouterRv`, { jour, idClient, idCreneau })
       .pipe(map((reponse) => this.extraire(reponse)));
   }

   supprimerRv(idRv: number): Observable<void> {
     return this.http
       .post<Reponse<null>>(`${this.settings.apiBaseUrl()}/supprimerRv`, { idRv })
       .pipe(map(() => undefined));
   }

   private extraire<T>(reponse: Reponse<T>): T {
     if (reponse.status !== 0) {
       throw new Error(`Le serveur a répondu avec le statut d'erreur ${reponse.status}`);
     }
     return reponse.data as T;
   }
 }

Commentons ce code :

  • ligne 8 : [export class RdvService {] — l’équivalent direct du service dao présenté au chapitre « Exemple 6 : les services HTTP » du document original (lequel utilisait le service AngularJS $http). Contrairement à une application serveur classique où la couche web ne parle à la couche DAO qu’au travers de la couche métier, le client s’autorise à ce que la couche de présentation (nos composants) appelle directement ce service - il n’y a pas de « couche métier » séparée côté client ;
  • ligne 9 : [private readonly http = inject(HttpClient);] — inject() est la façon moderne d’obtenir une dépendance en [Angular] (elle remplace l’injection par constructeur historique) ;
  • lignes 12-16 : [getAllMedecins(): Observable<Medecin[]> { … }] — équivalent de GET /getAllMedecins ; chaque méthode correspond très exactement à l’une des onze routes du contrôleur [NestJS] (chapitre 3) - on retrouve d’ailleurs les mêmes noms des deux côtés, ce qui facilite la lecture croisée serveur/client ;
  • ligne 44 : [private extraire(reponse: Reponse): T {] — plutôt que de répéter, dans chacune des méthodes publiques, le test « si status !== 0, c’est une erreur », on le centralise une seule fois ici ;
  • ligne 46 : [throw new Error(…)] — une exception JavaScript levée dans map() est automatiquement redirigée par RxJS vers la branche error de l’abonnement (.subscribe({ next, error })) - c’est ce mécanisme qui permet au composant racine d’afficher un message d’erreur sans jamais avoir à connaître le détail de l’enveloppe [Reponse]<T>.

6.6.3. src/app/core/services/auth.service.ts

Le service qui gère la connexion de l’utilisateur : appelle POST /login, garde le jeton JWT reçu (et l’identité/rôle qui l’accompagnent) sous forme de signaux, et le fait survivre à un rechargement de page grâce à localStorage. C’est le seul autre endroit de l’application, avec [RdvService], qui parle HTTP au serveur - une exception assumée à la règle « un seul service HTTP », qui reflète, côté client, la séparation déjà faite côté serveur entre RdvMedecinsController et AuthController.

  import { Injectable, computed, inject, signal } from '@angular/core';
  import { HttpClient } from '@angular/common/http';
  import { Observable, map, tap } from 'rxjs';
  import { SettingsService } from './settings.service';
  import { LoginResultat, Reponse, Role } from '../models/rdv.models';

  interface SessionStockee {
    accessToken: string;
    login: string;
   nom: string;
   role: Role;
 }

 const CLE_STOCKAGE = 'rdvmedecins.session';

 @Injectable({ providedIn: 'root' })
 export class AuthService {
   private readonly http = inject(HttpClient);
   private readonly settings = inject(SettingsService);

   private readonly session = signal<SessionStockee | null>(lireSessionStockee());
   readonly estConnecte = computed(() => this.session() !== null);
   readonly login = computed(() => this.session()?.login ?? null);
   readonly nom = computed(() => this.session()?.nom ?? null);
   readonly role = computed(() => this.session()?.role ?? null);
   readonly estAdmin = computed(() => this.role() === 'ADMIN');

   readonly accessToken = computed(() => this.session()?.accessToken ?? null);

   seConnecter(login: string, password: string): Observable<LoginResultat> {
     return this.http
       .post<Reponse<LoginResultat>>(`${this.settings.apiBaseUrl()}/login`, { login, password })
       .pipe(
         map((reponse) => {
           if (reponse.status !== 0 || reponse.data === null) {
             throw new Error('Échec de connexion');
           }
           return reponse.data;
         }),
         tap((resultat) => this.enregistrerSession(resultat)),
       );
   }

   seDeconnecter(): void {
     this.session.set(null);
     localStorage.removeItem(CLE_STOCKAGE);
   }

   private enregistrerSession(resultat: LoginResultat): void {
     const nouvelleSession: SessionStockee = resultat;
     this.session.set(nouvelleSession);
     localStorage.setItem(CLE_STOCKAGE, JSON.stringify(nouvelleSession));
   }
 }

 function lireSessionStockee(): SessionStockee | null {
   try {
     const brut = localStorage.getItem(CLE_STOCKAGE);
     return brut ? (JSON.parse(brut) as SessionStockee) : null;
   } catch {
     return null;
   }
 }

Commentons ce code :

  • lignes 7-12 : [interface SessionStockee { … }] — la forme de ce qu’on écrit/lit dans localStorage - un simple objet JSON, distinct de [LoginResultat] par construction (même s’il en reprend tous les champs) ;
  • ligne 21 : [private readonly session = signal<SessionStockee | null>(lireSessionStockee());] — initialisé depuis localStorage : si l’utilisateur recharge la page après s’être connecté, il reste connecté (jusqu’à expiration du jeton côté serveur, ou déconnexion explicite) ;
  • ligne 22 : [readonly estConnecte = computed(() => this.session() !== null);] — computed(...) crée un signal dérivé : sa valeur se recalcule automatiquement dès que session change, et tout composant qui le lit (auth.estConnecte() dans [app.html]) est notifié à son tour ;
  • lignes 23-26 : [login / nom / role / estAdmin] — signaux dérivés, exposés en lecture seule aux composants (app.ts, login.component.ts, agenda.component.ts via peutModifier) ;
  • ligne 28 : [readonly accessToken = computed(() => this.session()?.accessToken ?? null);] — le jeton à joindre à chaque requête HTTP protégée, lu par auth.interceptor.ts (ci-dessous) ;
  • ligne 30 : [seConnecter(login: string, password: string): Observable {] — équivalent de POST /login, corps JSON { login, password } ;
  • lignes 34-39 : [map((reponse) => { if (reponse.status !== 0 …) { throw …; } return reponse.data; })] — même principe que RdvService.extraire(), réécrit ici en ligne plutôt qu’appelé (pas de méthode privée partagée entre les deux services, qui restent volontairement indépendants) ;
  • ligne 40 : [tap((resultat) => this.enregistrerSession(resultat)),] — tap() exécute un effet de bord (ici : mémoriser la session) sans changer la valeur transmise ensuite à l’abonné - contrairement à map(), qui transforme cette valeur ;
  • lignes 44-47 : [seDeconnecter(): void { this.session.set(null); localStorage.removeItem(…); }] — purement locale (pas d’appel serveur) : un jeton JWT ne se « révoque » pas côté serveur dans ce premier portage, il expire de lui-même après JWT_EXPIRES_IN ;
  • ligne 56 : [function lireSessionStockee(): SessionStockee | null {] — protégée par un try/catch : localStorage peut être indisponible (navigation privée très restrictive) ou contenir une valeur corrompue - dans ce cas, on considère simplement qu’il n’y a pas de session.

6.6.4. src/app/core/services/language.service.ts

Un petit service qui centralise le changement de langue (français/anglais) de l’interface. Il s’appuie sur [TranslateService] ([@ngx-translate/core], configuré dans app.config.ts ci-après) pour le travail de traduction proprement dit, et y ajoute ce qu’on retrouve déjà dans [SettingsService] et [AuthService] : un signal exposé au reste de l’application, et une mémorisation du choix de l’utilisateur dans localStorage.

  import { Injectable, inject } from '@angular/core';
  import { TranslateService } from '@ngx-translate/core';

  const CLE_STOCKAGE = 'rdvmedecins.langue';

  export type Langue = 'fr' | 'en';

  @Injectable({ providedIn: 'root' })
  export class LanguageService {
   private readonly translate = inject(TranslateService);

   readonly langueCourante = this.translate.currentLang;

   constructor() {
     const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null;
     if (langueMemorisee && langueMemorisee !== this.translate.getCurrentLang()) {
       this.appliquerLangue(langueMemorisee);
     }
   }

   changerLangue(langue: Langue): void {
     localStorage.setItem(CLE_STOCKAGE, langue);
     this.appliquerLangue(langue);
   }

   private appliquerLangue(langue: Langue): void {
     this.translate.use(langue).subscribe(() => {
       document.documentElement.lang = langue;
     });
   }
 }

Commentons ce code :

  • ligne 10 : [private readonly translate = inject(TranslateService);] — [TranslateService] est le service central de [@ngx-translate/core] : c’est lui qui sait charger un dictionnaire de traduction et résoudre une clé ('LOGIN.TITLE') en un texte ('Connexion' ou '[Login]', selon la langue courante) ;
  • ligne 12 : [readonly langueCourante = this.translate.currentLang;] — depuis la version 18 de [@ngx-translate/core], [TranslateService] expose déjà currentLang comme un signal réactif : pas besoin d’en recréer un ici, on se contente de le republier sous ce nom - pour que le reste du code de l’application (cf. [app.html]) n’ait jamais besoin d’importer [TranslateService] lui-même, seulement [LanguageService] ;
  • lignes 14-19 : [constructor() { const langueMemorisee = …; if (…) { this.appliquerLangue(langueMemorisee); } }] — au démarrage : si l’utilisateur avait déjà choisi une langue lors d’une visite précédente, on la restaure - sinon, on garde la langue par défaut fixée dans app.config.ts (le français) ;
  • lignes 21-24 : [changerLangue(langue: Langue): void { localStorage.setItem(…); this.appliquerLangue(langue); }] — appelée par les deux boutons FR/EN de la barre de navigation (cf. [app.html]) ;
  • lignes 26-30 : [private appliquerLangue(langue: Langue): void { this.translate.use(langue).subscribe(() => { document.documentElement.lang = langue; }); }] — translate.use(langue) charge (au besoin) le fichier public/i18n/<langue>.json correspondant, puis bascule la langue courante ; c’est un Observable qui émet une fois le chargement terminé - on en profite pour mettre à jour l’attribut <html lang="..."> de la page (utile pour l’accessibilité), qu’[Angular] ne gère pas lui-même puisque index.html est une page statique, chargée avant même le démarrage de l’application.

Équivalent du bouton « FR / EN » du client AngularJS 1.x original, qui s’appuyait à l’époque sur la bibliothèque angular-translate - l’ancêtre, dans l’écosystème AngularJS 1.x, de ngx-translate utilisé ici.

6.7. Les dictionnaires de traduction (public/i18n/)

TranslateHttpLoader ([@ngx-translate/http-loader], configuré dans app.config.ts ci-après) charge l’un de ces deux fichiers par une requête HTTP GET [/i18n/]<langue>.json, chaque fois que la langue change (ou qu’elle est utilisée pour la première fois). [TranslateService] aplatit ensuite ce JSON en un dictionnaire "LOGIN.TITLE", "AGENDA.FREE"… interrogé par le pipe | translate employé dans tous les templates de features/ (cf. plus loin dans ce chapitre).

Image

6.7.1. public/i18n/fr.json

  {
    "APP": {
      "TITLE": "RdvMedecins - portage NestJS / Angular",
      "SERVER_URL_LABEL": "URL du serveur",
      "LOGOUT": "Se déconnecter"
    },
    "LOGIN": {
      "TITLE": "Connexion",
      "LOGIN_LABEL": "Login",
     "PASSWORD_LABEL": "Mot de passe",
     "SUBMIT": "Se connecter",
     "SUBMITTING": "Connexion...",
     "ERROR": "Login ou mot de passe incorrect.",
     "DEMO_ACCOUNTS_PREFIX": "Comptes de démonstration :",
     "DEMO_ACCOUNTS_ADMIN": "(rôle ADMIN, accès complet)",
     "DEMO_ACCOUNTS_OR": "ou",
     "DEMO_ACCOUNTS_USER": "(rôle USER, lecture seule)"
   },
   "DOCTOR_DAY_PICKER": {
     "DOCTOR_LABEL": "Médecin",
     "CHOOSE_DOCTOR": "-- choisir un médecin --",
     "DAY_LABEL": "Jour",
     "VIEW_AGENDA": "Voir l'agenda"
   },
   "AGENDA": {
     "TITLE_PREFIX": "Agenda du",
     "COLUMN_SLOT": "Créneau",
     "COLUMN_STATUS": "Statut",
     "FREE": "Libre",
     "BOOK": "Réserver",
     "CANCEL_APPOINTMENT": "Annuler",
     "EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda »."
   },
   "BOOKING_DIALOG": {
     "TITLE": "Réserver ce créneau",
     "CLOSE_ARIA": "Fermer",
     "SLOT_FROM": "Créneau de",
     "SLOT_TO": "à",
     "PATIENT_LABEL": "Patient",
     "CHOOSE_PATIENT": "-- choisir un patient --",
     "CANCEL": "Annuler",
     "CONFIRM": "Confirmer"
   }
 }

Commentons ce code :

  • ligne 1 : [{] — un objet JSON imbriqué, une section par composant (APP, LOGIN, DOCTOR_DAY_PICKER, AGENDA, BOOKING_DIALOG) ; [TranslateService] l’aplatit en un dictionnaire à plat, où chaque clé complète ("LOGIN.TITLE") est formée en joignant le chemin par des points ;
  • lignes 14-17 : [DEMO_ACCOUNTS_PREFIX / DEMO_ACCOUNTS_ADMIN / DEMO_ACCOUNTS_OR / DEMO_ACCOUNTS_USER] — une phrase découpée en quatre clés plutôt qu’une seule : le texte complet du document original (« Comptes de démonstration : admin / admin (rôle ADMIN…) ou user / user (rôle USER…) ») contient aussi des éléments <code>admin</code> qui ne doivent PAS être traduits (ce sont des identifiants de connexion, identiques dans les deux langues) - il faut donc pouvoir les intercaler entre des fragments traduits (cf. login.component.html plus loin) ;
  • ligne 32 : [EMPTY_STATE: Choisissez un médecin et un jour, puis cliquez sur « Voir l’agenda ».] — contrairement à la plupart des autres clés (de simples libellés), celle-ci est une phrase complète : rien n’empêche une clé de traduction de contenir de la ponctuation ou plusieurs mots, du moment qu’elle correspond à un seul bloc de texte dans le template.

6.7.2. public/i18n/en.json

  {
    "APP": {
      "TITLE": "RdvMedecins - NestJS / Angular port",
      "SERVER_URL_LABEL": "Server URL",
      "LOGOUT": "Log out"
    },
    "LOGIN": {
      "TITLE": "Login",
      "LOGIN_LABEL": "Login",
     "PASSWORD_LABEL": "Password",
     "SUBMIT": "Log in",
     "SUBMITTING": "Signing in...",
     "ERROR": "Incorrect login or password.",
     "DEMO_ACCOUNTS_PREFIX": "Demo accounts:",
     "DEMO_ACCOUNTS_ADMIN": "(ADMIN role, full access)",
     "DEMO_ACCOUNTS_OR": "or",
     "DEMO_ACCOUNTS_USER": "(USER role, read-only)"
   },
   "DOCTOR_DAY_PICKER": {
     "DOCTOR_LABEL": "Doctor",
     "CHOOSE_DOCTOR": "-- choose a doctor --",
     "DAY_LABEL": "Day",
     "VIEW_AGENDA": "View schedule"
   },
   "AGENDA": {
     "TITLE_PREFIX": "Schedule for",
     "COLUMN_SLOT": "Slot",
     "COLUMN_STATUS": "Status",
     "FREE": "Free",
     "BOOK": "Book",
     "CANCEL_APPOINTMENT": "Cancel",
     "EMPTY_STATE": "Choose a doctor and a day, then click "View schedule"."
   },
   "BOOKING_DIALOG": {
     "TITLE": "Book this slot",
     "CLOSE_ARIA": "Close",
     "SLOT_FROM": "Slot from",
     "SLOT_TO": "to",
     "PATIENT_LABEL": "Patient",
     "CHOOSE_PATIENT": "-- choose a patient --",
     "CANCEL": "Cancel",
     "CONFIRM": "Confirm"
   }
 }

Commentons ce code :

  • ligne 3 : [TITLE: RdvMedecins - NestJS / Angular port,] — seul le sous-titre change : « [RdvMedecins] » reste tel quel dans les deux langues, c’est le nom propre de l’application ;
  • ligne 11 : [SUBMIT: Log in,] — traduction adaptée plutôt que littérale (« Connect » aurait été un calque du français) : le vocabulaire d’authentification standard en anglais distingue log in (verbe, l’action de se connecter) de login (nom, l’identifiant saisi - cf. ligne 9, resté "[Login]" dans les deux langues, puisque c’est aussi le nom du champ attendu par le serveur, cf. [LoginDto] au chapitre 3).

Les deux fichiers doivent rester structurellement identiques (mêmes clés, dans les deux) : c’est ce qui garantit qu’une clé existe dans les deux langues. Une clé absente d’un des deux fichiers s’afficherait telle quelle (son nom brut) plutôt que sa traduction, le temps qu’on la complète.

Ce qui n’est PAS traduit : les messages d’erreur renvoyés par le serveur [NestJS] (cf. chapitre 3, enveloppe [Reponse]<T>) restent en français quelle que soit la langue choisie côté client - traduire ces messages nécessiterait une internationalisation côté serveur, hors du périmètre de cette étape (cf. chapitre 6, conclusion). Les noms de médecins et de patients, eux, viennent directement des données de la base et ne sont bien sûr traduits dans aucun des deux cas.

6.8. La couche core/interceptors

Image

6.8.1. src/app/core/interceptors/auth.interceptor.ts

Un intercepteur HTTP s’intercale entre [HttpClient] et le réseau : il voit passer chaque requête sortante (et sa réponse), et peut les modifier. C’est l’équivalent moderne des intercepteurs $http d’AngularJS 1.x déjà rencontrés dans le document original (chapitre « Exemple 6 ») - la forme a changé (une simple fonction plutôt qu’un objet avec des méthodes request/response), l’idée reste la même.

  import { inject } from '@angular/core';
  import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
  import { catchError, throwError } from 'rxjs';
  import { AuthService } from '../services/auth.service';

  export const authInterceptor: HttpInterceptorFn = (request, next) => {
    const auth = inject(AuthService);
    const jeton = auth.accessToken();

   const requeteAvecJeton = jeton
     ? request.clone({ setHeaders: { Authorization: `Bearer ${jeton}` } })
     : request;

   return next(requeteAvecJeton).pipe(
     catchError((erreur: unknown) => {
       if (erreur instanceof HttpErrorResponse && erreur.status === 401) {
         auth.seDeconnecter();
       }
       return throwError(() => erreur);
     }),
   );
 };

Commentons ce code :

  • ligne 6 : [export const authInterceptor: HttpInterceptorFn = (request, next) => {] — depuis quelques versions, [Angular] préfère les intercepteurs fonctionnels ([HttpInterceptorFn]) - une simple fonction - aux intercepteurs à base de classe des premières versions d’[Angular] : plus courts à écrire, plus simples à enregistrer (cf. app.config.ts, withInterceptors([...])) ;
  • lignes 10-12 : [request.clone({ setHeaders: { Authorization: `Bearer ${jeton}` } }) : request;] — une requête [HttpClient] est un objet immuable - on ne peut pas modifier ses entêtes directement, il faut en fabriquer une copie modifiée. C’est cette copie (requeteAvecJeton) qu’il faut transmettre à la suite de la chaîne, jamais la requête d’origine inchangée ;
  • lignes 15-18 : [catchError((erreur) => { if (erreur instanceof HttpErrorResponse && erreur.status === 401) { auth.seDeconnecter(); } … })] — un 401 en cours de session signifie que le jeton n’est plus valide (expiré, ou serveur redémarré) : on déconnecte proprement le client plutôt que de le laisser dans un état incohérent (connecté en apparence, mais dont plus aucune requête n’aboutit) ;
  • ligne 19 : [return throwError(() => erreur);] — on relance l’erreur telle quelle après ce traitement : l’intercepteur ne doit pas cacher l’erreur au code appelant ([AgendaComponent] via [App], par exemple), seulement réagir à un cas particulier au passage.

On pourrait ajouter Authorization: Bearer ... à la main dans chacune des méthodes de RdvService. Un intercepteur évite cette répétition : la question « comment authentifie-t-on une requête ? » est répondue à un seul endroit, une bonne fois pour toutes, et s’appliquera automatiquement à tout futur service qui utiliserait HttpClient. Il est enregistré dans app.config.ts (ci-dessous), via provideHttpClient(withInterceptors([authInterceptor])) : sans cette ligne, la fonction existerait mais ne serait jamais appelée.

6.9. Configuration de l’application

Image

6.9.1. src/app/app.config.ts

  import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
  import { provideHttpClient, withInterceptors } from '@angular/common/http';
  import { provideRouter } from '@angular/router';
  import { provideTranslateService } from '@ngx-translate/core';
  import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
  import { routes } from './app.routes';
  import { authInterceptor } from './core/interceptors/auth.interceptor';

  export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideRouter(routes),
     provideHttpClient(withInterceptors([authInterceptor])),
     provideTranslateService({
       lang: 'fr',
       fallbackLang: 'fr',
       loader: provideTranslateHttpLoader({ prefix: '/i18n/', suffix: '.json' }),
     }),
   ],
 };

Commentons ce code :

  • ligne 9 : [export const appConfig: ApplicationConfig = {] — ce que le composant racine ([App]) reçoit pour pouvoir fonctionner. C’est une notion propre aux applications [Angular] « standalone » (sans NgModule) : chaque provideXxx() active un service transverse de l’application ; il n’y a pas d’équivalent direct de ce fichier dans le projet AngularJS 1.x original ;
  • ligne 12 : [provideRouter(routes),] — active le routeur Angular. Notre application n’a qu’une seule page : c’est un composant ([LoginComponent]), affiché conditionnellement par [App], qui joue le rôle d’écran de connexion - pas une route dédiée (cf. app.routes.ts ci-dessous) ;
  • ligne 13 : [provideHttpClient(withInterceptors([authInterceptor])),] — active [HttpClient], utilisé par [RdvService] et [AuthService] - l’équivalent moderne du service AngularJS 1.x $http. withInterceptors([authInterceptor]) enregistre l’intercepteur d’authentification : sans cette ligne, authInterceptor existerait mais ne serait jamais appelé ;
  • lignes 14-18 : [provideTranslateService({ lang: fr, fallbackLang: fr, loader: provideTranslateHttpLoader({ prefix: /i18n/, suffix: .json }), }),] — active [@ngx-translate/core], la bibliothèque utilisée pour le passage français/anglais de l’interface. lang: 'fr' fixe la langue de démarrage ([LanguageService], cf. plus haut, la remplacera aussitôt par le choix mémorisé dans localStorage, s’il y en a un) ; fallbackLang: 'fr' serait utilisée si une clé de traduction manquait dans le dictionnaire de la langue courante ; loader: provideTranslateHttpLoader(...) indique COMMENT charger les dictionnaires - ici par une requête HTTP GET sur des fichiers JSON statiques (public/i18n/fr.json, public/i18n/en.json, cf. plus haut), prefix/suffix formant l’URL complète (prefix + langue + suffix, soit par exemple /i18n/fr.json).

6.9.2. src/app/app.routes.ts

1
2
3
  import { Routes } from '@angular/router';

  export const routes: Routes = [];

Commentons ce code :

  • ligne 3 : [export const routes: Routes = [];] — la table de routage reste vide : l’application tient sur une seule page (le composant racine [App] affiche soit l’écran de connexion, soit le sélecteur médecin/jour + l’agenda + la fenêtre de réservation, selon AuthService.estConnecte()). On aurait pu créer une route /connexion plutôt qu’un affichage conditionnel, mais pour une seule page ce détour n’aurait rien apporté.

6.10. La couche features : les quatre composants

Image

6.10.1. src/app/features/login/login.component.ts

L’écran de connexion : équivalent de la vue [login.html] du client AngularJS 1.x original (première vue affichée par l’application).

  import { Component, DestroyRef, inject, signal } from '@angular/core';
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
  import { TranslatePipe } from '@ngx-translate/core';
  import { AuthService } from '../../core/services/auth.service';

  @Component({
    selector: 'app-login',
    imports: [TranslatePipe],
    templateUrl: './login.component.html',
   styleUrl: './login.component.css',
 })
 export class LoginComponent {
   private readonly auth = inject(AuthService);
   private readonly destroyRef = inject(DestroyRef);

   protected readonly login = signal('');
   protected readonly password = signal('');

   protected readonly enCours = signal(false);
   protected readonly erreur = signal<string | null>(null);

   onChangementLogin(event: Event): void {
     this.login.set((event.target as HTMLInputElement).value);
   }

   onChangementPassword(event: Event): void {
     this.password.set((event.target as HTMLInputElement).value);
   }

   onValider(): void {
     if (this.login().trim() === '' || this.password() === '') {
       return;
     }
     this.erreur.set(null);
     this.enCours.set(true);
     this.auth
       .seConnecter(this.login(), this.password())
       .pipe(takeUntilDestroyed(this.destroyRef))
       .subscribe({
         next: () => this.enCours.set(false),
         error: () => {
           this.enCours.set(false);
           this.erreur.set('LOGIN.ERROR');
         },
       });
   }
 }

Commentons ce code :

  • ligne 8 : [imports: [TranslatePipe],] — un composant standalone déclare lui-même les pipes qu’il utilise dans son template ; [TranslatePipe] ([@ngx-translate/core]) est ce qui rend disponible | translate dans login.component.html (ci-dessous) ;
  • lignes 16-17 : [protected readonly login = signal(’); protected readonly password = signal(’);] — état purement local au formulaire (ce que l’utilisateur est en train de taper) ;
  • ligne 19 : [protected readonly enCours = signal(false);] — vrai pendant l’appel réseau : désactive le bouton pour éviter un double clic ;
  • ligne 20 : [protected readonly erreur = signal<string | null>(null);] — mémorise une clé de traduction, pas un texte déjà traduit : c’est le pipe | translate du template qui la résout au moment de l’affichage ;
  • ligne 30 : [onValider(): void {] — déclenchée par le bouton « Se connecter » (cf. le template ci-dessous) ;
  • ligne 40 : [next: () => this.enCours.set(false),] — pas d’autre traitement à faire ici : [AuthService] a déjà mémorisé la session (signal + localStorage) - c’est [App], qui lit auth.estConnecte(), qui réagira en cessant d’afficher <app-login> ;
  • lignes 41-44 : [error: () => { …this.erreur.set(LOGIN.ERROR); }] — un couple login/mot de passe invalide fait échouer seConnecter() avec une erreur HTTP 401 (cf. chapitre 3, [LocalStrategy]) : on affiche simplement 'LOGIN.ERROR' à l’utilisateur (« [Login] ou mot de passe incorrect. » / « Incorrect login or password. », selon la langue), sans chercher à distinguer un login inexistant d’un mauvais mot de passe (par prudence, comme le ferait n’importe quelle application réelle). Mémoriser une clé plutôt qu’un texte déjà résolu a un avantage concret : si l’utilisateur bascule de langue pendant que ce message est affiché, il se retraduit tout seul, sans code supplémentaire ici.

6.10.2. src/app/features/login/login.component.html

  <div class="row justify-content-center">
    <div class="col-sm-8 col-md-6 col-lg-4">
      <div class="card p-4">
        <h5 class="card-title mb-3">{{ 'LOGIN.TITLE' | translate }}</h5>

        @if (erreur(); as messageErreur) {
          <div class="alert alert-danger py-2" role="alert">{{ messageErreur | translate }}</div>
        }

       <div class="mb-3">
         <label class="form-label" for="input-login">{{ 'LOGIN.LOGIN_LABEL' | translate }}</label>
         <input id="input-login" type="text" class="form-control" autocomplete="username"
                [value]="login()" (input)="onChangementLogin($event)" (keyup.enter)="onValider()">
       </div>

       <div class="mb-3">
         <label class="form-label" for="input-password">{{ 'LOGIN.PASSWORD_LABEL' | translate }}</label>
         <input id="input-password" type="password" class="form-control" autocomplete="current-password"
                [value]="password()" (input)="onChangementPassword($event)" (keyup.enter)="onValider()">
       </div>

       <button type="button" class="btn btn-primary w-100" [disabled]="enCours()" (click)="onValider()">
         @if (enCours()) { {{ 'LOGIN.SUBMITTING' | translate }} } @else { {{ 'LOGIN.SUBMIT' | translate }} }
       </button>

       <p class="text-muted small mt-3 mb-0">
         {{ 'LOGIN.DEMO_ACCOUNTS_PREFIX' | translate }} <code>admin</code> / <code>admin</code> {{ 'LOGIN.DEMO_ACCOUNTS_ADMIN' | translate }}
         {{ 'LOGIN.DEMO_ACCOUNTS_OR' | translate }} <code>user</code> / <code>user</code> {{ 'LOGIN.DEMO_ACCOUNTS_USER' | translate }}.
       </p>
     </div>
   </div>
 </div>

Commentons ce code :

  • lignes 1-2 : [] — classes de grille Bootstrap : centre horizontalement une carte de largeur limitée, quelle que soit la largeur de l’écran ;
  • lignes 12-13, 18-19 : [(input)=onChangementLogin($event) (keyup.enter)=onValider()] — pas de FormsModule/ngModel : les champs sont lus directement via l’événement DOM (input), et (keyup.enter) permet de valider le formulaire au clavier, sans souris ;
  • ligne 22 : [[disabled]=enCours()] — le bouton se désactive pendant l’appel réseau (cf. LoginComponent.enCours), pour éviter un double clic pendant que la première tentative est encore en cours ;
  • lignes 27-28 : [{{ LOGIN.DEMO_ACCOUNTS_PREFIX | translate }} admin / admin {{ LOGIN.DEMO_ACCOUNTS_ADMIN | translate }} …] — la phrase des comptes de démonstration alterne texte traduit et identifiants non traduits (<code>admin</code>, <code>user</code>) : c’est pour permettre ce mélange qu’elle a été découpée en quatre clés distinctes dans les dictionnaires JSON (cf. plus haut, public/i18n/fr.json), plutôt qu’une seule clé contenant toute la phrase.

6.10.3. src/app/features/doctor-day-picker/doctor-day-picker.component.ts

Le composant qui permet de choisir un médecin et un jour, puis de demander à voir son agenda. Équivalent, en esprit, des exemples 7 à 10 du client AngularJS 1.x original.

  import { Component, input, output, signal } from '@angular/core';
  import { TranslatePipe } from '@ngx-translate/core';
  import { Medecin } from '../../core/models/rdv.models';

  @Component({
    selector: 'app-doctor-day-picker',
    imports: [TranslatePipe],
    templateUrl: './doctor-day-picker.component.html',
    styleUrl: './doctor-day-picker.component.css',
 })
 export class DoctorDayPickerComponent {
   readonly medecins = input.required<Medecin[]>();
   readonly rechercher = output<{ idMedecin: number; jour: string }>();

   readonly idMedecinSelectionne = signal<number | null>(null);
   readonly jourSelectionne = signal<string>(new Date().toISOString().slice(0, 10));

   onChangementMedecin(event: Event): void {
     const valeur = (event.target as HTMLSelectElement).value;
     this.idMedecinSelectionne.set(valeur === '' ? null : Number(valeur));
   }

   onChangementJour(event: Event): void {
     this.jourSelectionne.set((event.target as HTMLInputElement).value);
   }

   onClicRechercher(): void {
     const idMedecin = this.idMedecinSelectionne();
     if (idMedecin === null) {
       return;
     }
     this.rechercher.emit({ idMedecin, jour: this.jourSelectionne() });
   }
 }

Commentons ce code :

  • ligne 7 : [imports: [TranslatePipe],] — rend disponible | translate dans doctor-day-picker.component.html (ci-dessous) ;
  • ligne 12 : [readonly medecins = input.required<Medecin[]>();] — déclare que ce composant attend obligatoirement la liste des médecins de la part de son parent ([App]) - [Angular] signale une erreur si ce composant est utilisé sans que cette entrée soit fournie. input() (basé sur les signaux) remplace le mot-clé @Input() historique : [medecins]() se lit comme une fonction, ce qui permet à [Angular] de savoir précisément quand redessiner le composant ;
  • ligne 13 : [readonly rechercher = output<{ idMedecin: number; jour: string }>();] — déclare l’événement que ce composant remonte à son parent - équivalent d’un événement personnalisé ($emit/$broadcast) en AngularJS 1.x ;
  • lignes 15-16 : [idMedecinSelectionne / jourSelectionne] — état purement local à ce composant (ce qui est sélectionné dans les menus, tant que l’utilisateur n’a pas cliqué sur le bouton) ; jourSelectionne est initialisé à la date du jour ;
  • lignes 27-33 : [onClicRechercher(): void { … this.rechercher.emit({ idMedecin, jour: … }); }] — ne fait aucun appel HTTP lui-même : il se contente de remonter l’intention de l’utilisateur à son parent, qui décidera d’appeler RdvService.

6.10.4. src/app/features/doctor-day-picker/doctor-day-picker.component.html

  <div class="card p-3 mb-3">
    <div class="row g-2 align-items-end">
      <div class="col-sm-5">
        <label class="form-label" for="select-medecin">{{ 'DOCTOR_DAY_PICKER.DOCTOR_LABEL' | translate }}</label>
        <select id="select-medecin" class="form-select" (change)="onChangementMedecin($event)">
          <option value="">{{ 'DOCTOR_DAY_PICKER.CHOOSE_DOCTOR' | translate }}</option>
          @for (medecin of medecins(); track medecin.id) {
            <option [value]="medecin.id">{{ medecin.titre }} {{ medecin.prenom }} {{ medecin.nom }}</option>
          }
       </select>
     </div>
     <div class="col-sm-4">
       <label class="form-label" for="input-jour">{{ 'DOCTOR_DAY_PICKER.DAY_LABEL' | translate }}</label>
       <input id="input-jour" type="date" class="form-control" [value]="jourSelectionne()" (change)="onChangementJour($event)">
     </div>
     <div class="col-sm-3">
       <button type="button" class="btn btn-primary w-100" [disabled]="idMedecinSelectionne() === null" (click)="onClicRechercher()">
         {{ 'DOCTOR_DAY_PICKER.VIEW_AGENDA' | translate }}
       </button>
     </div>
   </div>
 </div>

Commentons ce code :

  • ligne 7 : [@for (medecin of medecins(); track medecin.id) {] — [@for] est la nouvelle syntaxe de contrôle de flux d’[Angular] (remplace *ngFor) : elle répète une <option> pour chaque médecin reçu du composant parent. track medecin.[id] indique à [Angular] comment reconnaître un médecin déjà affiché s’il est redessiné ;
  • ligne 17 : [[disabled]=idMedecinSelectionne() === null] — le bouton reste désactivé tant qu’aucun médecin n’est sélectionné, pour éviter une recherche incomplète ;
  • lignes 4, 6, 13, 18 : [{{ | translate }}] — les quatre libellés statiques de ce composant (« Médecin », « – choisir un médecin – », « Jour », « Voir l’agenda »), traduits via le pipe | translate (cf. [LanguageService] plus haut). Le nom de chaque médecin (ligne 8), lui, vient directement des données du serveur et n’est jamais traduit.

6.10.5. src/app/features/agenda/agenda.component.ts

Affiche l’agenda d’un médecin pour un jour donné : la liste de ses créneaux horaires, chacun étant libre (bouton « Réserver ») ou occupé (nom du patient + bouton « Annuler »). Équivalent des exemples 8 et 9 du client AngularJS 1.x original.

  import { Component, input, output } from '@angular/core';
  import { TranslatePipe } from '@ngx-translate/core';
  import { AgendaMedecinJour, CreneauAgenda, CreneauJson, RvJson } from '../../core/models/rdv.models';

  @Component({
    selector: 'app-agenda',
    imports: [TranslatePipe],
    templateUrl: './agenda.component.html',
    styleUrl: './agenda.component.css',
 })
 export class AgendaComponent {
   readonly agenda = input<AgendaMedecinJour | null>(null);

   readonly peutModifier = input<boolean>(true);

   readonly reserver = output<CreneauJson>();
   readonly annuler = output<RvJson>();

   formaterHeure(h: number, m: number): string {
     return `${h}:${m.toString().padStart(2, '0')}`;
   }

   onClicCreneau(creneauAgenda: CreneauAgenda): void {
     if (!this.peutModifier()) {
       return;
     }
     if (creneauAgenda.rv === null) {
       this.reserver.emit(creneauAgenda.creneau);
     } else {
       this.annuler.emit(creneauAgenda.rv);
     }
   }
 }

Commentons ce code :

  • ligne 12 : [readonly agenda = input<AgendaMedecinJour | null>(null);] — input() sans .required autorise une valeur par défaut (ici, null) : vaut null tant qu’aucune recherche n’a été faite ;
  • ligne 14 : [readonly peutModifier = input(true);]nouveauté apportée par l’authentification : true par défaut (rôle ADMIN) ; [App] lui transmet auth.estAdmin() (cf. [app.html]). À false (rôle USER), les boutons « Réserver »/« Annuler » disparaissent du template (cf. ci-dessous) - l’agenda reste consultable, mais en lecture seule ;
  • lignes 23-26 : [onClicCreneau(creneauAgenda: CreneauAgenda): void { if (!this.peutModifier()) { return; } … }] — ce garde applicatif est redondant avec le masquage du bouton dans le template (un rôle USER ne voit jamais ce bouton) : il protège malgré tout contre un appel programmatique de cette méthode, et documente l’intention au même endroit que le reste de la logique. Rappel : c’est de toute façon le serveur ([RolesGuard], chapitre 3) qui reste l’unique rempart réel - un client [Angular] modifié à la main ne pourrait pas contourner cette restriction ;
  • lignes 27-31 : [if (creneauAgenda.rv === null) { this.reserver.emit(…); } else { this.annuler.emit(…); }] — créneau libre : on propose à l’utilisateur de le réserver ; créneau déjà occupé : on propose d’annuler le rendez-vous existant. Ce composant ne fait aucun appel HTTP lui-même : il affiche des données reçues, et notifie son parent des intentions de l’utilisateur.

6.10.6. src/app/features/agenda/agenda.component.html

  @if (agenda(); as monAgenda) {
    <div class="card p-3">
      <h5>{{ 'AGENDA.TITLE_PREFIX' | translate }} {{ monAgenda.jour }} - {{ monAgenda.medecin.titre }} {{ monAgenda.medecin.prenom }} {{ monAgenda.medecin.nom }}</h5>

      <table class="table table-hover align-middle">
        <thead>
          <tr><th>{{ 'AGENDA.COLUMN_SLOT' | translate }}</th><th>{{ 'AGENDA.COLUMN_STATUS' | translate }}</th><th></th></tr>
        </thead>
        <tbody>
         @for (creneauAgenda of monAgenda.creneaux; track creneauAgenda.creneau.id) {
           <tr [class.creneau-libre]="creneauAgenda.rv === null">
             <td>{{ formaterHeure(creneauAgenda.creneau.hDebut, creneauAgenda.creneau.mDebut) }} - {{ formaterHeure(creneauAgenda.creneau.hFin, creneauAgenda.creneau.mFin) }}</td>
             <td>
               @if (creneauAgenda.rv === null) {
                 <span class="badge text-bg-success">{{ 'AGENDA.FREE' | translate }}</span>
               } @else {
                 <span class="badge text-bg-secondary">{{ creneauAgenda.rv.client?.titre }} {{ creneauAgenda.rv.client?.prenom }} {{ creneauAgenda.rv.client?.nom }}</span>
               }
             </td>
             <td>
               @if (peutModifier()) {
                 <button type="button" class="btn btn-sm" [class.btn-outline-success]="creneauAgenda.rv === null"
                         [class.btn-outline-danger]="creneauAgenda.rv !== null" (click)="onClicCreneau(creneauAgenda)">
                   {{ creneauAgenda.rv === null ? ('AGENDA.BOOK' | translate) : ('AGENDA.CANCEL_APPOINTMENT' | translate) }}
                 </button>
               }
             </td>
           </tr>
         }
       </tbody>
     </table>
   </div>
 } @else {
   <p class="text-muted">{{ 'AGENDA.EMPTY_STATE' | translate }}</p>
 }

Commentons ce code :

  • ligne 1 : [@if (agenda(); as monAgenda) {] — @if/@for sont la nouvelle syntaxe de contrôle de flux d’[Angular] : elles remplacent respectivement *ngIf et *ngFor, et font partie du langage de template lui-même (aucun import de module nécessaire). as monAgenda capture la valeur non nulle dans une variable de template, évitant de répéter agenda()! partout ensuite ;
  • lignes 14-18 : [@if (creneauAgenda.rv === null) { ... } @else { ... }] — badge Bootstrap vert (« Libre »/« Free ») ou gris (nom du patient), selon l’état du créneau ;
  • ligne 21 : [@if (peutModifier()) {]nouveauté apportée par l’authentification : le bouton « Réserver »/« Annuler » n’existe même pas dans le DOM pour un rôle USER (peutModifier() vaut alors false) - pas seulement grisé/désactivé, entièrement absent ;
  • ligne 24 : [{{ creneauAgenda.rv === null ? (AGENDA.BOOK | translate) : (AGENDA.CANCEL_APPOINTMENT | translate) }}] — un pipe peut s’utiliser à l’intérieur de n’importe quelle expression de template, y compris un opérateur ternaire, du moment que chaque branche est correctement parenthésée.

6.10.7. src/app/features/booking-dialog/booking-dialog.component.ts

La fenêtre (modale) qui permet de choisir un patient pour réserver un créneau libre. Équivalent de l’exemple 9 du client AngularJS 1.x original.

  import { Component, input, output, signal } from '@angular/core';
  import { TranslatePipe } from '@ngx-translate/core';
  import { Client, CreneauJson } from '../../core/models/rdv.models';

  @Component({
    selector: 'app-booking-dialog',
    imports: [TranslatePipe],
    templateUrl: './booking-dialog.component.html',
    styleUrl: './booking-dialog.component.css',
 })
 export class BookingDialogComponent {
   readonly ouvert = input.required<boolean>();
   readonly creneau = input<CreneauJson | null>(null);
   readonly clients = input.required<Client[]>();

   readonly confirmer = output<{ idClient: number }>();
   readonly fermer = output<void>();

   readonly idClientSelectionne = signal<number | null>(null);

   onChangementClient(event: Event): void {
     const valeur = (event.target as HTMLSelectElement).value;
     this.idClientSelectionne.set(valeur === '' ? null : Number(valeur));
   }

   onClicConfirmer(): void {
     const idClient = this.idClientSelectionne();
     if (idClient === null) {
       return;
     }
     this.confirmer.emit({ idClient });
     this.idClientSelectionne.set(null);
   }

   onClicFermer(): void {
     this.idClientSelectionne.set(null);
     this.fermer.emit();
   }
 }

Commentons ce code :

  • ligne 12 : [readonly ouvert = input.required();] — le composant reste toujours présent dans le template du parent ([App]) : c’est cette entrée qui commande, via [@if] (cf. le template ci-dessous), son affichage ou non ;
  • lignes 26-33 : [onClicConfirmer(): void { … this.confirmer.emit({ idClient }); this.idClientSelectionne.set(null); }] — remonte l’id du patient choisi à [App], qui appellera alors RdvService.ajouterRv(...) ; on réinitialise idClientSelectionne pour la prochaine ouverture de la fenêtre ;
  • lignes 35-38 : [onClicFermer(): void { … this.fermer.emit(); }] — si l’utilisateur annule, le parent referme simplement la fenêtre, sans appel au serveur.

6.10.8. src/app/features/booking-dialog/booking-dialog.component.html

Ce composant utilise désormais les vraies classes Bootstrap d’une modale (.modal, .modal-dialog, .modal-content, .modal-backdrop…), mais sa visibilité reste pilotée par [Angular] ([@if] (ouvert())) plutôt que par le JavaScript de Bootstrap (bootstrap.bundle.js, new bootstrap.Modal(...)).

  @if (ouvert()) {
    <div class="modal-backdrop fade show"></div>

    <div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true">
      <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title">{{ 'BOOKING_DIALOG.TITLE' | translate }}</h5>
            <button type="button" class="btn-close" [attr.aria-label]="'BOOKING_DIALOG.CLOSE_ARIA' | translate" (click)="onClicFermer()"></button>
         </div>

         <div class="modal-body">
           @if (creneau(); as monCreneau) {
             <p>{{ 'BOOKING_DIALOG.SLOT_FROM' | translate }} {{ monCreneau.hDebut }}:{{ monCreneau.mDebut.toString().padStart(2, '0') }}
                {{ 'BOOKING_DIALOG.SLOT_TO' | translate }} {{ monCreneau.hFin }}:{{ monCreneau.mFin.toString().padStart(2, '0') }}</p>
           }
           <label class="form-label" for="select-client">{{ 'BOOKING_DIALOG.PATIENT_LABEL' | translate }}</label>
           <select id="select-client" class="form-select" (change)="onChangementClient($event)">
             <option value="">{{ 'BOOKING_DIALOG.CHOOSE_PATIENT' | translate }}</option>
             @for (client of clients(); track client.id) {
               <option [value]="client.id">{{ client.titre }} {{ client.prenom }} {{ client.nom }}</option>
             }
           </select>
         </div>

         <div class="modal-footer">
           <button type="button" class="btn btn-secondary" (click)="onClicFermer()">{{ 'BOOKING_DIALOG.CANCEL' | translate }}</button>
           <button type="button" class="btn btn-primary" [disabled]="idClientSelectionne() === null" (click)="onClicConfirmer()">
             {{ 'BOOKING_DIALOG.CONFIRM' | translate }}
           </button>
         </div>
       </div>
     </div>
   </div>
 }

Commentons ce code :

  • ligne 2 : [] — le fond semi-transparent qui assombrit le reste de la page - fourni « en dur » ici, alors que Bootstrap l’insère d’habitude lui-même via JavaScript au moment où l’on affiche la modale ;
  • ligne 4 : [<div class=modal fade show d-block …>] — d-block remplace le rôle normalement tenu par le JavaScript de Bootstrap (ajouter display:block au moment de l’ouverture) - ici, c’est directement le [@if] de la ligne 1 qui joue ce rôle ;
  • lignes 6-10, 12-24, 26-31 : [.modal-content / .modal-header / .modal-body / .modal-footer] — la structure standard d’une modale Bootstrap (en-tête avec titre + croix de fermeture, corps, pied avec les actions) ;
  • ligne 9 : [[attr.aria-label]=“‘BOOKING_DIALOG.CLOSE_ARIA | translate] — la croix de fermeture standard Bootstrap (.btn-close), reliée au même gestionnaire que le bouton « Annuler »/« Cancel » du pied de modale (ligne 27) ; son texte accessible (aria-label) passe par une liaison d’attribut ([attr.xxx]) plutôt qu’une simple interpolation {{ }}, seule syntaxe possible dès que la valeur d’un attribut HTML n’est pas un texte statique.

Pourquoi ne pas utiliser le JavaScript de Bootstrap ? Parce que Bootstrap et [Angular] géreraient alors, chacun de son côté, la même information (la modale est-elle ouverte ?) - une source classique de bugs subtils dans une application Angular. En laissant ouvert() (un signal, donc l’état [Angular]) piloter seul l’affichage, on n’a qu’une seule source de vérité, tout en gardant l’apparence visuelle exacte d’une modale Bootstrap. Le document original (2014) utilisait un composant de la bibliothèque angular-ui-bootstrap (une modale AngularJS 1.x « clé en main »), déjà conçue selon ce même principe : état géré par [Angular], apparence empruntée à Bootstrap.

6.11. Le composant racine [App] : chef d’orchestre et garde d’authentification

C’est ce composant qui joue le rôle tenu par le « contrôleur principal » de l’application AngularJS 1.x originale : il détient l’état global de l’application et réagit aux événements des composants de features/ pour appeler [RdvService] au bon moment. Depuis l’ajout de l’authentification, il joue un second rôle : celui de « garde » côté présentation, qui décide d’afficher l’écran de connexion ou le reste de l’application.

Image

6.11.1. src/app/app.ts

  import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
  import { TranslatePipe } from '@ngx-translate/core';
  import { RdvService } from './core/services/rdv.service';
  import { SettingsService } from './core/services/settings.service';
  import { AuthService } from './core/services/auth.service';
  import { LanguageService } from './core/services/language.service';
  // ... modèles, composants (cf. imports détaillés du fichier)

 @Component({
   selector: 'app-root',
   imports: [DoctorDayPickerComponent, AgendaComponent, BookingDialogComponent, LoginComponent, TranslatePipe],
   templateUrl: './app.html',
   styleUrl: './app.css',
 })
 export class App {
   private readonly rdv = inject(RdvService);
   private readonly destroyRef = inject(DestroyRef);
   protected readonly settings = inject(SettingsService);
   protected readonly auth = inject(AuthService);
   protected readonly langue = inject(LanguageService);

   protected readonly medecins = signal<Medecin[]>([]);
   protected readonly clients = signal<Client[]>([]);
   protected readonly agenda = signal<AgendaMedecinJour | null>(null);
   protected readonly erreur = signal<string | null>(null);
   protected readonly creneauEnReservation = signal<CreneauJson | null>(null);

   private dernierIdMedecin: number | null = null;
   private dernierJour: string | null = null;

   constructor() {
     effect(() => {
       if (this.auth.estConnecte()) {
         this.chargerListesInitiales();
       }
     });
   }

   private chargerListesInitiales(): void {
     this.rdv.getAllMedecins().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
       next: (medecins) => this.medecins.set(medecins),
       error: (err) => this.erreur.set(String(err.message ?? err)),
     });
     this.rdv.getAllClients().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
       next: (clients) => this.clients.set(clients),
       error: (err) => this.erreur.set(String(err.message ?? err)),
     });
   }

   onDeconnexion(): void {
     this.auth.seDeconnecter();
     this.medecins.set([]);
     this.clients.set([]);
     this.agenda.set(null);
     this.erreur.set(null);
     this.creneauEnReservation.set(null);
     this.dernierIdMedecin = null;
     this.dernierJour = null;
   }

   onRechercherAgenda(criteres: { idMedecin: number; jour: string }): void {
     this.erreur.set(null);
     this.dernierIdMedecin = criteres.idMedecin;
     this.dernierJour = criteres.jour;
     this.chargerAgenda();
   }

   onDemandeReservation(creneau: CreneauJson): void {
     this.creneauEnReservation.set(creneau);
   }

   onConfirmerReservation(choix: { idClient: number }): void {
     const creneau = this.creneauEnReservation();
     if (creneau === null || this.dernierJour === null) {
       return;
     }
     this.rdv.ajouterRv(this.dernierJour, choix.idClient, creneau.id)
       .pipe(takeUntilDestroyed(this.destroyRef))
       .subscribe({
         next: () => { this.creneauEnReservation.set(null); this.chargerAgenda(); },
         error: (err) => this.erreur.set(String(err.message ?? err)),
       });
   }

   onFermerReservation(): void {
     this.creneauEnReservation.set(null);
   }

   onAnnulerRv(rv: RvJson): void {
     this.rdv.supprimerRv(rv.id).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
       next: () => this.chargerAgenda(),
       error: (err) => this.erreur.set(String(err.message ?? err)),
     });
   }

   private chargerAgenda(): void {
     if (this.dernierIdMedecin === null || this.dernierJour === null) {
       return;
     }
     this.rdv.getAgendaMedecinJour(this.dernierIdMedecin, this.dernierJour)
       .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: (agenda) => this.agenda.set(agenda),
        error: (err) => this.erreur.set(String(err.message ?? err)),
      });
  }
 }

Commentons ce code :

  • ligne 12 : [imports: [DoctorDayPickerComponent, AgendaComponent, BookingDialogComponent, LoginComponent, TranslatePipe],] — un composant standalone déclare lui-même les composants et pipes qu’il utilise dans son template - [LoginComponent] a rejoint cette liste avec l’authentification, [TranslatePipe] avec la traduction ;
  • ligne 20 : [protected readonly auth = inject(AuthService);] — exposé au template (protected, pas private) pour le badge rôle/nom et le bouton de déconnexion (cf. [app.html]) ;
  • ligne 21 : [protected readonly langue = inject(LanguageService);] — exposé au template pour les deux boutons FR/EN de la barre de navigation, toujours visibles (y compris sur l’écran de connexion) contrairement au reste de l’interface ;
  • ligne 33 : [effect(() => { if (this.auth.estConnecte()) { this.chargerListesInitiales(); } });] — effect() ré-exécute son corps chaque fois qu’un signal qu’il lit change - ici, estConnecte(). Il s’exécute aussi une première fois immédiatement : si une session était déjà mémorisée dans localStorage (rechargement de page), les listes se chargent dès le démarrage, sans attendre un nouveau login ;
  • ligne 40 : [private chargerListesInitiales(): void {] — au moment où l’on devient connecté (login réussi, ou session restaurée), on charge une bonne fois pour toutes les listes de médecins et de clients - équivalent, côté client cette fois, de ce que faisait [ApplicationModelService] au démarrage du serveur (chapitre 3). Toutes les routes du serveur étant désormais protégées par [JwtAuthGuard], il serait de toute façon inutile (et source d’erreurs 401) de faire cet appel avant d’être connecté ;
  • ligne 51 : [onDeconnexion(): void {] — réagit au bouton « Se déconnecter » : purge la session et tout l’état métier affiché (lignes 53-59) - sans cela, un nouvel utilisateur qui se connecterait ensuite verrait un instant l’agenda du précédent ;
  • lignes 41, 45, etc. : [.pipe(takeUntilDestroyed(this.destroyRef))] — un abonnement RxJS (.subscribe(...)) reste actif tant qu’on ne s’en désabonne pas explicitement, un oubli fréquent, source de fuites mémoire. Cet opérateur désabonne automatiquement dès que le composant est détruit ;
  • ligne 66 : [onDemandeReservation(creneau: CreneauJson): void { this.creneauEnReservation.set(creneau); }]n’appelle pas encore le serveur : elle se contente d’ouvrir la fenêtre de réservation. C’est onConfirmerReservation (ligne 70), déclenchée par l’événement (confirmer) de [BookingDialogComponent], qui appelle réellement ajouterRv ;
  • lignes 78, 89 : [this.chargerAgenda();] — après une réservation ou une annulation réussie, on rappelle chargerAgenda() : c’est ce qui permet à l’agenda affiché de refléter immédiatement le nouvel état, sans que l’utilisateur ait besoin de rafraîchir la page.

6.11.2. src/app/app.html

  <div class="container py-4">
    <nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
      <span class="navbar-brand mb-0">{{ 'APP.TITLE' | translate }}</span>

      <div class="d-flex align-items-center gap-2">
        <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
          <button type="button" class="btn"
                  [class.btn-light]="langue.langueCourante() === 'fr'"
                  [class.btn-outline-light]="langue.langueCourante() !== 'fr'"
                 (click)="langue.changerLangue('fr')">FR</button>
         <button type="button" class="btn"
                 [class.btn-light]="langue.langueCourante() === 'en'"
                 [class.btn-outline-light]="langue.langueCourante() !== 'en'"
                 (click)="langue.changerLangue('en')">EN</button>
       </div>

       @if (auth.estConnecte()) {
         <span class="badge text-bg-light text-primary">{{ auth.role() }}</span>
         <span class="text-white">{{ auth.nom() }}</span>
         <button type="button" class="btn btn-sm btn-outline-light" (click)="onDeconnexion()">
           {{ 'APP.LOGOUT' | translate }}
         </button>
       }
     </div>
   </nav>

   <div class="row g-2 align-items-center mb-4">
     <div class="col-auto"><label class="form-label mb-0" for="input-url-serveur">{{ 'APP.SERVER_URL_LABEL' | translate }}</label></div>
     <div class="col-sm-4">
       <input id="input-url-serveur" type="text" class="form-control form-control-sm"
              [value]="settings.apiBaseUrl()" (change)="settings.setApiBaseUrl($any($event.target).value)">
     </div>
   </div>

   @if (erreur(); as messageErreur) {
     <div class="alert alert-danger" role="alert">{{ messageErreur }}</div>
   }

   @if (!auth.estConnecte()) {
     <app-login />
   } @else {
     <app-doctor-day-picker [medecins]="medecins()" (rechercher)="onRechercherAgenda($event)" />

     <app-agenda
       [agenda]="agenda()"
       [peutModifier]="auth.estAdmin()"
       (reserver)="onDemandeReservation($event)"
       (annuler)="onAnnulerRv($event)"
     />

     <app-booking-dialog
       [ouvert]="creneauEnReservation() !== null"
       [creneau]="creneauEnReservation()"
       [clients]="clients()"
       (confirmer)="onConfirmerReservation($event)"
       (fermer)="onFermerReservation()"
     />
   }
 </div>

Commentons ce code :

  • ligne 2 : [] — classes Bootstrap dédiées à un en-tête d’application : un bandeau de couleur (bg-primary), du texte clair (navbar-dark), et un alignement horizontal automatique de son contenu ;
  • ligne 3 : [{{ APP.TITLE | translate }}] — premier exemple du pipe translate ([@ngx-translate/core]) : au lieu du texte français en dur, une clé (APP.TITLE) résolue dans le dictionnaire de la langue courante (public/i18n/fr.json ou en.json) ;
  • lignes 6-15 : [<div class=btn-group btn-group-sm …> … FR … EN … ] — le sélecteur de langue, toujours visible (avant comme après connexion, contrairement au badge de rôle) : deux boutons Bootstrap groupés (btn-group), dont l’un des deux est mis en évidence (btn-light plutôt que btn-outline-light) selon la langue courante (langue.langueCourante()), et dont le clic appelle langue.changerLangue('fr' | 'en') ;
  • lignes 17-23 : [@if (auth.estConnecte()) { ... badge + nom + bouton de déconnexion ... }] — ce bloc n’apparaît qu’une fois connecté ; auth.[role]() alimente un badge Bootstrap (badge text-bg-light), auth.[nom]() affiche le nom de l’utilisateur, et le texte du bouton passe lui aussi par | translate (clé APP.LOGOUT) ;
  • ligne 28 : [{{ APP.SERVER_URL_LABEL | translate }}] — même mécanisme que pour le titre, appliqué au libellé du champ « URL du serveur » ;
  • lignes 27-33 : [URL du serveur] — reste affiché avant comme après connexion (utile pour pointer vers un autre serveur [NestJS] avant même de tenter un login) ;
  • ligne 39 : [@if (!auth.estConnecte()) { <app-login /> } @else { ... }] — le cœur du rôle de « garde » de [App] : tant que l’utilisateur n’est pas connecté, rien d’autre que l’écran de connexion n’est affiché - ni le sélecteur médecin/jour, ni l’agenda, ni la fenêtre de réservation ;
  • ligne 46 : [[peutModifier]=auth.estAdmin()] — transmet à [AgendaComponent] si l’utilisateur connecté a le droit de réserver/annuler (rôle ADMIN) ou non (rôle USER, lecture seule) ;
  • lignes 51-57 : [<app-booking-dialog … />] — toujours présent dans le DOM (comme avant l’authentification) : c’est son entrée ouvert qui commande sa visibilité.

Notons que le sélecteur de langue est placé en dehors du bloc [@if] (auth.estConnecte()) : il doit rester utilisable dès l’écran de connexion, avant tout jeton JWT - c’est d’ailleurs sur cet écran que la capture d’écran ci-dessous (« [Login] screen ») illustre le passage en anglais.

6.12. Utilisation pas à pas de l’application

Les captures d’écran suivantes ont été réalisées pendant la préparation de ce document, contre une copie de test du serveur seedée avec les données de démonstration (3 médecins dont Mme Marie PELISSIER, 4 clients dont Brigitte BISTROU, et les deux comptes admin/admin et user/user).

6.12.1. 1. Écran de connexion

Tant que l’utilisateur n’est pas connecté, seul l’écran de connexion est affiché - le reste de l’application (sélecteur médecin/jour, agenda) n’existe pas encore dans la page. Les deux boutons « FR »/« EN » du bandeau, eux, sont déjà utilisables à ce stade - aucun jeton n’est requis pour changer de langue :

Image

Écran de connexion

Une tentative avec un mauvais mot de passe affiche un message d’erreur, sans autre précision (pour ne pas révéler si c’est le login ou le mot de passe qui est en cause) :

Image

Erreur de connexion

Un clic sur « EN » traduit instantanément l’écran, y compris ce message d’erreur s’il est affiché au moment du changement de langue - conséquence directe du choix, expliqué plus haut dans ce chapitre (section login.component.ts), de stocker dans erreur() une clé de traduction plutôt qu’un texte français déjà résolu :

Image

Écran de connexion en anglais

6.12.2. 2. Connexion en tant qu’administrateur (rôle ADMIN)

Une fois connecté avec le compte admin/admin, l’en-tête affiche le rôle (badge « ADMIN ») et le nom de l’utilisateur, avec un bouton « Se déconnecter ». Le sélecteur médecin/jour apparaît, comme avant l’authentification :

Image

Accueil, une fois connecté en ADMIN

Le passage en anglais reste disponible une fois connecté ; il traduit alors aussi le badge de rôle, le nom des libellés et le bouton de déconnexion (« Log out ») :

Image

Accueil, une fois connecté en ADMIN, en anglais

Après avoir choisi un médecin et cliqué sur « Voir l’agenda », chaque créneau libre porte un bouton « Réserver », chaque créneau occupé un bouton « Annuler » : le rôle ADMIN a un accès complet, exactement comme avant l’ajout des rôles :

Image

Agenda, vue ADMIN (boutons Réserver/Annuler visibles)

Un clic sur « Réserver » ouvre la fenêtre modale Bootstrap, proposant de choisir un patient :

Image

Fenêtre de réservation (modale Bootstrap)

Après avoir choisi un patient et cliqué sur « Confirmer », la fenêtre se ferme et l’agenda se rafraîchit automatiquement : le créneau réservé apparaît désormais occupé, avec le nom du patient choisi :

Image

Agenda après réservation

Un clic sur « Annuler » (créneau occupé) rafraîchit l’agenda de la même façon - le créneau redevient « Libre » :

Image

Agenda après annulation

6.12.3. 3. Connexion en tant qu’utilisateur (rôle USER, lecture seule)

Après déconnexion, une connexion avec le compte user/user affiche un badge « USER » différent :

Image

Accueil, une fois connecté en USER

L’agenda reste consultable, mais aucun bouton « Réserver »/« Annuler » n’apparaît : la colonne d’actions est vide pour chaque créneau. Une tentative directe sur le serveur (par exemple avec Postman, en présentant le jeton de user) recevrait de toute façon une réponse 403 Forbidden - le masquage des boutons n’est qu’un confort d’affichage, la restriction réelle est appliquée par [RolesGuard] côté serveur (chapitre 3) :

Image

Agenda, vue USER (lecture seule, aucun bouton d’action)

6.13. Ce qui reste hors périmètre ou reporté

Le mode « debug » (affichage du modèle brut de la vue courante) du client AngularJS 1.x original est une fonctionnalité délibérément laissée de côté dans ce portage - elle n’apparaîtra pas dans une étape ultérieure du cours (cf. chapitre 6, conclusion, pour la justification de ce choix). Le réglage d’un délai réseau artificiel, lui, reste effectivement reporté à une étape ultérieure.