Skip to content

6. Chapter 5 - The [Angular] client of the [RdvMedecins] application

This chapter details, file by file, the contents of the [rdvmedecins-angular-client] folder included with this document, using the format adopted throughout this course: source code numbered line by line, followed by a section titled “Let’s Comment on This Code:”. Each project folder also contains its own README.md file.

6.1. Architecture Overview

As in the original document, the client follows a layered architecture, which we will refer to here as V-C-Services (View-Controller-Services):

Image

One difference in terminology is worth noting: in AngularJS and 1.x, the View (the HTML template) and the Controller (the associated JavaScript class) were two distinct entities, linked by $scope. In [Angular], they are combined into a single entity, the component (see previous chapter)—which is why, in what follows, we will simply refer to “components” where the original document distinguished between view and controller.

As in the original document, components do not communicate directly with JAMAIS: this role is exclusively reserved for the Services layer. This is a design principle that applies equally to AngularJS, 1.x, and [Angular]—it has not changed.

6.2. Project Tree

Image

This structure directly follows the flow of the original document (login, select doctor/day, view schedule, make appointment), simply organized here into standalone [Angular] components rather than separate AngularJS and 1.x controllers and views.

Bootstrap is used in all views ([node_modules/bootstrap/dist/css/bootstrap.min.css], declared in angular.json)—a direct evolution of Bootstrap 3, which was already used by the original document.

6.3. Project configuration files

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"
   }
 }

Let’s comment on this code:

  • lines 4–10: [“scripts”: { … }] — the available `npm run <name>` commands. `start` (`ng serve`) launches the development server with automatic reloading whenever a source file is modified—this is the command used throughout this document;
  • lines 13–25: [“dependencies”: { … }] — the packages required to run the application in the browser: the core of [Angular] ([@angular/core], [@angular/common], [@angular/compiler], [@angular/forms], [@angular/platform-browser], [@angular/router]), Bootstrap (style sheet only; see angular.json), RxJS (the Observables used by [HttpClient]), and—introduced in this chapter—[@ngx-translate/core] and [@ngx-translate/http-loader], the third-party library used for the FR/EN translation of the interface (see [LanguageService] and app.config.ts below);
  • lines 26–32: [“devDependencies”: { … }] — packages required only during development (CLI, compiler), never included in the final bundle delivered to the browser: @angular/cli/@angular/build (CLI and its compilation engine), [@angular/compiler-cli] (ahead-of-time template compilation), TypeScript, and Prettier (automatic code formatting, not used in this document but included in every [Angular] project generated by default).

Unlike the [NestJS] server (commonjs module, see Chapter 3), a default [Angular] project runs as native ECMAScript modules (import/export)—this is reflected below by "module": "preserve" in 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"
         }
       }
     }
   }
 }

Let’s comment on this code (a few lines have been condensed above for readability—the delivered file is complete):

  • line 11: [“prefix”: “app”,] — the prefix applied to the selector of each project component (app-login, app-agenda, etc.)—a convention, enforced by the compiler, that prevents name collisions with potential third-party components;
  • line 16: [“browser”: “src/main.ts”,] — the entry point for the compilation—the same file as the one explained later in this chapter;
  • lines 18–20: [“assets”: [ { “glob”: “**/*”, “input”: “public” } ],] — copies the entire contents of the public/ folder, as-is, to the root of the compiled application — this mechanism is what allows public/i18n/fr.json to be accessible at URL/i18n/fr.json, the address queried by TranslateHttpLoader (see later in this chapter);
  • lines 21–24: [“styles”: [ “node_modules/bootstrap/dist/css/bootstrap.min.css”, “src/styles.css” ],] — the global style sheets, concatenated in this order at compile time: Bootstrap first (so that our own rules, in [styles.css], can override it if necessary), without using a standard @import statement for CSS.

This file has no equivalent in the original AngularJS 1.x project (the compilation tools at the time—Grunt, Gulp—were configured separately); in the [NestJS] project (Chapter 3), nest-cli.json serves a similar purpose, but in a simpler way.

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" }
   ]
 }

Let’s break down this code:

  • line 4: [“strict”: true,] — unlike the [NestJS] server (which only enables strictNullChecks in isolation; see Chapter 3), the client enables the full strict mode of TypeScript: every variable must have a determinable type, and every null/undefined value must be handled explicitly. This is a more stringent choice, consistent with the fact that this document presents [Angular] as the benchmark for “current best practices”;
  • line 20: [“strictTemplates”: true]the most important option in this file: it extends the type checking from TypeScript into the HTML templates themselves ([app.html], agenda.component.html…). An error such as {{ medecin.nomm }} (typo) or [peutModifier]="auth.estAdmin" (missing parentheses) is then flagged during compilation, just like an error in a .ts file—which was not the case in AngularJS 1.x (2014), where such a template would always compile, with the error only becoming apparent at runtime (silently, most of the time);
  • Lines 22–25: [“files”: [], “references”: [ { “path”: “./tsconfig.app.json” } ]] — this root file does not directly compile any files ("files": []): it delegates to tsconfig.app.json (below) via the TypeScript project references mechanism, which allows a single [Angular] project to ultimately define several distinct build configurations (application, tests, etc.) that share a common foundation.

6.3.4. tsconfig.app.json

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

Let’s comment on this code:

  • line 2: [“extends”: “./tsconfig.json”,] — builds on the common foundation (strict mode, target ES2022…) rather than redefining everything;
  • line 8: [“src/**/*.ts”] — all .ts files in the project, including (for example) language.service.ts, added by this chapter;
  • line 11: [“src/**/*.spec.ts”] — unit test files (not present in this project, but excluded by convention) are compiled separately, with their own configuration—not covered in this document.

6.4. Starting the 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>

Let’s comment on this code:

  • line 2: [] — the page’s default language, useful for accessibility and search engines. Since this page, HTML, is static (loaded by the browser AVANT when the application [Angular] starts), it obviously cannot immediately reflect a language choice made later by the user; it is [LanguageService] (see below) that dynamically updates this attribute as soon as the user switches to English;
  • line 11: [] — the root component selector ([App], see app.ts: selector: 'app-root'): this is the only element the page contains initially, and [Angular] fills it entirely once it starts up. This follows exactly the same principle as in AngularJS 1.x (2014), which set ng-app="rdvmedecinsApp" on the <html> tag in the [app.html] file of the original project—a nearly empty HTML page, entirely controlled by the JavaScript framework.

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

Let’s comment on this code:

  • line 5: [bootstrapApplication(App, appConfig).catch((err) => console.error(err));] — starts the application by specifying the root component ([App]) and its configuration (appConfig; see app.config.ts below) ; bootstrapApplication is the startup function for standalone [Angular] applications (without NgModule), which replaces platformBrowserDynamic().bootstrapModule([AppModule]) from older versions of Angular. .catch(...) catches any errors that occur during startup itself (before any lines of the application’s code are executed)—a scenario rare and serious enough to warrant separate handling from the application’s usual error handling.

There is only one entry point for the entire application: this is the very definition of a single-page web application (SPA), as already described in the original document.

6.4.3. [src/styles.css]

  body {
    background-color: #f5f7fa;
  }

  .creneau-libre {
    cursor: pointer;
  }

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

Let’s comment on this code:

  • line 1: [body { background-color: #f5f7fa; }] — a very light gray rather than the default pure white, so that the Bootstrap cards (white background, .card) stand out slightly from the page background;
  • lines 5–10: [.creneau-libre] — applied to the rows in the calendar table representing an available time slot (see agenda.component.html, [class.creneau-libre]="creneauAgenda.rv === null"): the cursor changes to a hand (cursor: pointer) and the background turns slightly green on hover, to suggest that the row is clickable—even before clicking on it.

This file contains global styles, as opposed to the “local” styles defined in each component’s .css file (agenda.component.css…), which apply only to that specific component’s template. src/app/app.css, the style sheet for the root component, remains empty: Bootstrap and [styles.css] are sufficient for everything this document implements.

6.5. The core/models layer

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;
 }

Let’s comment on this code:

  • lines 1–4: [export interface Reponse { status: number; data: T | null; }] — corresponds exactly to the server class [Reponse]<T> (see Chapter 3): every JSON response has this form;
  • lines 6–18: [Medecin / Client] — two interfaces identical in form, corresponding to the [TypeORM] entities of the same name (Chapter 3). Reminder: [Client] refers here to a patient, not a “HTTP client”;
  • lines 20–33: [CreneauJson / RvJson] — the “light” versions generated on the server side by getMapForCreneau/getMapForRv (Chapter 3, static.helper.ts);
  • lines 40–44: [export interface AgendaMedecinJour { … }] — corresponds to the response from GET /getAgendaMedecinJour/:idMedecin/:day;
  • line 46: [export type Role = ‘ADMIN’ | ‘USER’;] — an exact copy of the [Role] type from the server (src/entities/user.entity.ts);
  • Lines 48–53: [export interface LoginResultat { … }] — the format of the data returned by POST /login (see [AuthService] below), an exact copy of the server interface of the same name (auth/login-resultat.model.ts).

In AngularJS and 1.x (2014), the original JavaScript did not allow the expected data format to be described in this way: a server response was treated as just any object, without any validation being performed before execution. With TypeScript, a typo in a field name is now flagged even before the program is executed—during compilation.

6.6. The core/services layer

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);
    }
 }

Let’s comment on this code:

  • Line 3: [@Injectable({ providedIn: 'root' })] — declares a service as a global singleton for the application: [Angular] automatically creates a single instance of it the first time a component needs it—the modern equivalent of a service AngularJS 1.x declared with .service() or .factory();
  • Line 5: [readonly apiBaseUrl = signal(‘http://localhost:8080’);] — signal(...) creates a reactive value: anyone who reads apiBaseUrl() in a [Angular] template will be automatically notified if its value changes - the core reactivity mechanism of modern [Angular], which replaces observation ($watch/$scope.$apply) of AngularJS and 1.x;
  • lines 7–9: [setApiBaseUrl(url: string): void { this.apiBaseUrl.set(url); }] — taken from the “URL on the server” field on the original document’s home view, which already allowed the user to modify it from the interface (useful if the server is running on a different 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;
   }
 }

Let’s comment on this code:

  • line 8: [export class RdvService {] — the direct equivalent of the DAO service presented in the chapter “Example 6: The HTTP Services” of the original document (which used the AngularJS and $http services). Unlike a traditional server-side application, where the web layer communicates with the DAO layer only through the business layer, the client allows the presentation layer (our components) to call this service directly—there is no separate “business layer” on the client side;
  • line 9: [private readonly http = inject(HttpClient);] — `inject()` is the modern way to obtain a dependency in [Angular] (it replaces the traditional constructor injection);
  • Lines 12–16: [getAllMedecins(): Observable<Medecin[]> { … }] — equivalent to GET / getAllMedecins; Each method corresponds exactly to one of the eleven routes in the [NestJS] controller (Chapter 3)—in fact, the same names appear on both sides, which makes it easier to cross-reference server and client code;
  • line 44: [private extraire(reponse: Reponse): T {] — rather than repeating the “if status !== 0, it’s an error” check in each of the public methods, we centralize it here just once;
  • line 46: [throw new Error(…)] — a JavaScript exception thrown in `map()` is automatically redirected by RxJS to the `error` branch of the subscription (.subscribe({ next, error })) - This mechanism allows the root component to display an error message without ever needing to know the details of the [Reponse]<T> envelope.

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

The service that manages the user’s connection: calls POST /login, stores the received JWT token (and the accompanying identity/role) as signals, and ensures it survives a page reload using localStorage. This is the only other place in the application, along with [RdvService], that communicates with the server using HTTP—an intentional exception to the “single HTTP service” rule, which reflects, on the client side, the separation already made on the server side between RdvMedecinsController and 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;
   }
 }

Let’s comment on this code:

  • lines 7–12: [interface SessionStockee { … }] — the format of what is written to/read from localStorage—a simple JSON object, distinct from [LoginResultat] by design (even though it includes all of its fields);
  • line 21: [private readonly session = signal<SessionStockee | null>(lireSessionStockee());] — initialized from localStorage: if the user reloads the page after logging in, they remain logged in (until the token expires on the server side or they explicitly log out);
  • line 22: [readonly estConnecte = computed(() => this.session() !== null);] — `computed()` creates a derived signal: its value is automatically recalculated whenever the session changes, and any component that reads it (auth.estConnecte() in [app.html]) is in turn notified;
  • lines 23–26: [login / nom / role / estAdmin] — derived signals, exposed as read-only to components (app.ts, login.component.ts, agenda.component.ts via peutModifier);
  • line 28: [readonly accessToken = computed(() => this.session()?.accessToken ?? null);] — the token to be included with each protected HTTP request, read by auth.interceptor.ts (below);
  • line 30: [seConnecter(login: string, password: string): Observable {] — equivalent to POST /login, body JSON { login, password };
  • lines 34–39: [map((reponse) => { if (reponse.status !== 0 …) { throw …; } return reponse.data; })] — same principle as RdvService.extraire(), rewritten here as a line rather than a call (no private method shared between the two services, which remain intentionally independent);
  • line 40: [tap((resultat) => this.enregistrerSession(resultat)),] — `tap()` performs a side effect (here: saving the session) without changing the value subsequently passed to the subscriber—unlike `map()`, which transforms that value;
  • lines 44–47: [seDeconnecter(): void { this.session.set(null); localStorage.removeItem(…); }] — purely local (no server call): a token JWT is not “revoked” on the server side in this initial port; it expires on its own after JWT_EXPIRES_IN;
  • Line 56: [function lireSessionStockee(): SessionStockee | null {] — protected by a try/catch block: localStorage may be unavailable (due to very restrictive private browsing) or contain a corrupted value—in which case, we simply assume there is no session.

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

A small service that centralizes the interface’s language switching (French/English). It relies on [TranslateService] ([@ngx-translate/core], configured in app.config.ts below) for the actual translation work, and adds what is already found in [SettingsService] and [AuthService]: a signal exposed to the rest of the application, and storage of the user’s choice in 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;
     });
   }
 }

Let’s break down this code:

  • line 10: [private readonly translate = inject(TranslateService);] — [TranslateService] is the central service for [@ngx-translate/core]: it is responsible for loading a translation dictionary and resolving a key ('LOGIN.TITLE') into text ('Login' or '[Login]', depending on the current language);
  • Line 12: [readonly langueCourante = this.translate.currentLang;] — since version 18 of [@ngx-translate/core], [TranslateService] has already exposed currentLang as an event signal: there’s no need to recreate one here, we simply republish it under this name—so that the rest of the application code (see [app.html]) never needs to import [TranslateService] itself, only [LanguageService];
  • lines 14–19: [constructor() { const langueMemorisee = …; if (…) { this.appliquerLangue(langueMemorisee); } }]—at startup: if the user had already selected a language during a previous visit, it is restored; otherwise, the default language set in app.config.ts (French) is retained;
  • lines 21–24: [changerLangue(langue: Langue): void { localStorage.setItem(…); this.appliquerLangue(langue); }] — called by the two buttons FR/EN on the navigation bar (see [app.html]);
  • lines 26–30: [private appliquerLangue(langue: Langue): void { this.translate.use(langue).subscribe(() => { document.documentElement.lang = langue; }); }] — translate.use(language) loads (if necessary) the corresponding public/i18n/<language>.json file, then switches the current language; This is an Observable that emits a signal once loading is complete—we use this opportunity to update the <html lang="..."> attribute of the page (useful for accessibility), which [Angular] does not handle itself since index.html is a static page loaded even before the application starts.

Equivalent to the “FR / EN” button in the original AngularJS 1.x client, which at the time relied on the angular-translate library —the predecessor, within the AngularJS 1.x ecosystem, of ngx-translate used here.

6.7. Translation dictionaries (public/i18n/)

TranslateHttpLoader ([@ngx-translate/http-loader], configured in app.config.ts below) loads one of these two files via a request for HTTP GET [/i18n/]<language>.json, whenever the language changes (or is used for the first time). [TranslateService] then flattens this JSON into a dictionary "LOGIN.TITLE", "AGENDA.FREE"… which is queried by the | translate pipe used in all templates in the features/ directory (see later in this chapter).

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"
   }
 }

Let’s comment on this code:

  • line 1: [{] — a nested JSON object, one section per component (APP, LOGIN, DOCTOR_DAY_PICKER, AGENDA, BOOKING_DIALOG); [TranslateService] flattens it into a flat dictionary, where each complete key ("LOGIN.TITLE") is formed by joining the path with dots;
  • lines 14–17: [“DEMO_ACCOUNTS_PREFIX” / “DEMO_ACCOUNTS_ADMIN” / “DEMO_ACCOUNTS_OR” / “DEMO_ACCOUNTS_USER”] — a phrase split into four keys rather than a single one: the full text of the original document (“Demo accounts: admin / admin (role ADMIN…) or user / user (role USER…) ”) also contains elements <code>admin</code> that must not be translated (these are login credentials, identical in both languages)—they must therefore be inserted between translated fragments (see login.component.html below);
  • line 32: [“EMPTY_STATE”: “Choisissez un médecin et un jour, puis cliquez sur « Voir l’agenda ».”] — unlike most other keys (which are simple labels), this one is a complete sentence: there is nothing preventing a translation key from containing punctuation or multiple words, as long as it corresponds to a single block of text in the 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"
   }
 }

Let’s break down this code:

  • line 3: [“TITLE”: “RdvMedecins - NestJS / Angular port”,] — only the subtitle changes: “[RdvMedecins]” remains the same in both languages; this is the application’s proper name;
  • line 11: [“SUBMIT”: “Log in”,] — adapted rather than literal translation (“Connect” would have been a direct translation from French): standard English authentication terminology distinguishes between “log in” (verb, the action of logging in) and “login” (noun, the username entered—see line 9, which remains “[Login]” in both languages, since it is also the name of the field expected by the server; see [LoginDto] in Chapter 3).

Both files must remain structurally identical (same keys in both): this ensures that a key exists in both languages. A key missing from one of the two files would be displayed as-is (its raw name) rather than its translation, until it is added.

What is not translated: the error messages returned by the server (see Chapter 3, [Reponse]<T> envelope) remain in French regardless of the language selected on the client side—translating these messages would require server-side internationalization, which is outside the scope of this step (see Chapter 6, Conclusion). The names of doctors and patients, on the other hand, come directly from the database and are, of course, not translated in either case.

6.8. The core/interceptors layer

Image

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

A HTTP interceptor is inserted between [HttpClient] and the network: it intercepts every outgoing request (and its response) and can modify them. This is the modern equivalent of the $http, AngularJS, and 1.x interceptors already discussed in the original document (chapter “Example 6”) - The form has changed (a simple function rather than an object with request/response methods), but the idea remains the same.

  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);
     }),
   );
 };

Let’s comment on this code:

  • Line 6: [export const authInterceptor: HttpInterceptorFn = (request, next) => {] — For the past few versions, [Angular] has favored functional interceptors ([HttpInterceptorFn])—a simple function—over the class-based interceptors from the earlier versions of [Angular]: shorter to write, simpler to save (see app.config.ts, withInterceptors([...]));
  • lines 10–12: [request.clone({ setHeaders: { Authorization: `Bearer ${jeton}` } }) : request;] — a [HttpClient] request is an immutable object—you cannot modify its headers directly; you must create a modified copy. It is this copy (requeteAvecJeton) that must be passed along in the string, never the original, unmodified request;
  • lines 15–18: [catchError((erreur) => { if (erreur instanceof HttpErrorResponse && erreur.status === 401) { auth.seDeconnecter(); } … })] — a 401 error during a session means the token is no longer valid (expired, or the server has restarted): the client is properly disconnected rather than left in an inconsistent state (apparently connected, but with no requests succeeding);
  • line 19: [return throwError(() => erreur);] — the error is re-thrown as-is after this handling: the interceptor must not hide the error from the calling code ([AgendaComponent] via [App], for example), but only handle a specific case in the process.

We could manually add Authorization: Bearer ... to each of the methods in RdvService. An interceptor avoids this repetition: the question “How do we authenticate a request?” is answered in a single place, once and for all, and will automatically apply to any future service that uses HttpClient. It is registered in app.config.ts (below), via provideHttpClient(withInterceptors([authInterceptor])): without this line, the function would exist but would never be called.

6.9. Application Configuration

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' }),
     }),
   ],
 };

Let’s comment on this code:

  • Line 9: [export const appConfig: ApplicationConfig = {] — what the root component ([App]) receives in order to function. This is a concept specific to “standalone” [Angular] applications (without NgModule): each provideXxx() activates a cross-application service; there is no direct equivalent of this file in the original AngularJS 1.x project;
  • Line 12: [provideRouter(routes),] — activates the Angular router. Our application has only one page: it is a component ([LoginComponent]), conditionally displayed by [App], which serves as the login screen—not a dedicated route (see app.routes.ts below) ;
  • line 13: [provideHttpClient(withInterceptors([authInterceptor])),] — activates [HttpClient], used by [RdvService] and [AuthService] - the modern equivalent of the AngularJS, 1.x, and $http services. withInterceptors ([authInterceptor]) registers the authentication interceptor: without this line, authInterceptor would exist but would never be called;
  • lines 14–18: [provideTranslateService({ lang: ‘fr’, fallbackLang: ‘fr’, loader: provideTranslateHttpLoader({ prefix: ‘/i18n/’, suffix: ‘.json’ }), }),] — activates [@ngx-translate/core], the library used for switching the interface between French and English. lang: 'fr' sets the startup language ([LanguageService], see above, will immediately replace it with the selection stored in localStorage, if one exists); fallbackLang: 'fr' would be used if a translation key were missing from the current language's dictionary; loader: provideTranslateHttpLoader(...) instructs COMMENT to load the dictionaries —here via a query HTTP GET on static files JSON (public/i18n/fr.json, public/i18n/en.json, see above), with the prefix and suffix forming the complete URL (prefix + language + suffix, for example, /i18n/fr.json).

6.9.2. src/app/app.routes.ts

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

  export const routes: Routes = [];

Let’s comment on this code:

  • line 3: [export const routes: Routes = [];] — the routing table remains empty: the application fits on a single page (the root component [App] displays either the login screen or the doctor/day selector + the calendar + the booking window, depending on AuthService.estConnecte()). We could have created a /login route instead of a conditional display, but for a single-page application, this workaround would have served no purpose.

6.10. The features layer: the four components

Image

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

The login screen: equivalent to the view [login.html] of the original client AngularJS 1.x (the first view displayed by the 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');
         },
       });
   }
 }

Let’s comment on this code:

  • line 8: [imports: [TranslatePipe],] — a standalone component declares the pipes it uses in its template; [TranslatePipe] ([@ngx-translate/core]) is what makes | translate available in login.component.html (below);
  • Lines 16–17: [protected readonly login = signal(’‘); protected readonly password = signal(’’);] — state that is purely local to the form (what the user is currently typing);
  • line 19: [protected readonly enCours = signal(false);] — true during the network call: disables the button to prevent a double-click;
  • line 20: [protected readonly erreur = signal<string | null>(null);] — stores a translation key, not an already translated text: the | translate pipe in the template resolves it at display time;
  • line 30: [onValider(): void {] — triggered by the “Log In” button (see the template below);
  • line 40: [next: () => this.enCours.set(false),] — no further processing needed here: [AuthService] has already stored the session (signal + localStorage) — it is [App], which reads auth.estConnecte(), that will respond by stopping the display of <app-login>;
  • Lines 41–44: [error: () => { …this.erreur.set(‘LOGIN.ERROR’); }] — an invalid username/password combination causes seConnecter() to fail with error HTTP 401 (see Chapter 3, [LocalStrategy]): the user is simply shown 'LOGIN.ERROR' (“[Login] or incorrect password. ” / “Incorrect login or password.”, depending on the language), without attempting to distinguish between a nonexistent username and an incorrect password (as a precaution, as any real-world application would do). Storing a key rather than plaintext has a practical advantage: if the user switches languages while this message is displayed, it translates itself automatically, without requiring any additional code here.

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>

Let’s break down this code:

  • lines 1–2: [] — Bootstrap grid classes: centers a card with a fixed width horizontally, regardless of the screen width;
  • lines 12–13, 18–19: [(input)=“onChangementLogin($event)” (keyup.enter)=“onValider()”] — no FormsModule/ngModel: the fields are read directly via the DOM (input) event, and (keyup.enter) allows the form to be submitted using the keyboard, without a mouse;
  • line 22: [[disabled]=“enCours()”] — the button is disabled during the network call (see LoginComponent.enCours) to prevent a double-click while the first attempt is still in progress;
  • lines 27–28: [{{ ‘LOGIN.DEMO_ACCOUNTS_PREFIX’ | translate }} admin / admin {{ ‘LOGIN.DEMO_ACCOUNTS_ADMIN’ | translate }} …] — the text for demo accounts alternates between translated text and untranslated identifiers (<code>admin</code>, <code>user</code>): To allow for this mix, the phrase has been split into four separate keys in the JSON dictionaries (see above, public/i18n/fr.json), rather than a single key containing the entire phrase.

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

The component that allows you to select a doctor and a date, then view their schedule. Conceptually equivalent to examples 7 through 10 in the original AngularJS 1.x client.

  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() });
   }
 }

Let’s break down this code:

  • line 7: [imports: [TranslatePipe],] — makes | translate available in doctor-day-picker.component.html (below);
  • line 12: [readonly medecins = input.required<Medecin[]>();] — declares that this component must receive the list of doctors from its parent ([App]) — [Angular] reports an error if this component is used without this input being provided. input() (signal-based) replaces the legacy @Input() keyword: [medecins]() is treated as a function, which allows [Angular] to know exactly when to redraw the component;
  • line 13: [readonly rechercher = output<{ idMedecin: number; jour: string }>();] — declares the event that this component propagates to its parent—equivalent to a custom event ($emit/$broadcast) in AngularJS 1.x;
  • lines 15–16: [idMedecinSelectionne / jourSelectionne] — a state specific to this component (what is selected in the menus, as long as the user has not clicked the button); jourSelectionne is initialized to today’s date;
  • lines 27–33: [onClicRechercher(): void { … this.rechercher.emit({ idMedecin, jour: … }); }] — does not call HTTP itself: it simply passes the user’s intent up to its parent, which will decide whether to call 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>

Let’s break down this code:

  • line 7: [@for (medecin of medecins(); track medecin.id) {] — [@for] is the new flow control syntax for [Angular] (replaces *ngFor): it repeats a <option> for each doctor received from the parent component. track medecin.[id] tells [Angular] how to recognize a doctor already displayed if it is redrawn;
  • line 17: [[disabled]=“idMedecinSelectionne() === null”] — the button remains disabled as long as no doctor is selected, to prevent an incomplete search;
  • lines 4, 6, 13, 18: [{{ ‘…’ | translate }}] — the four static labels for this component (“Doctor,” “– select a doctor –,” “Day,” “View Calendar”), translated using the | translate pipe (see [LanguageService] above). The name of each doctor (line 8), however, comes directly from the server data and is never translated.

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

Displays a doctor’s schedule for a given day: a list of their time slots, each of which is either available (“Book” button) or booked (patient’s name + “Cancel” button). Equivalent to examples 8 and 9 from the original AngularJS 1.x client.

  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);
     }
   }
 }

Let’s comment on this code:

  • line 12: [readonly agenda = input<AgendaMedecinJour | null>(null);] — `input()` without `.required` allows a default value (here, `null`): it is `null` until a search is performed;
  • line 14: [readonly peutModifier = input(true);] a new feature introduced by authentication: true by default (role ADMIN); [App] passes auth.estAdmin() to it (see [app.html]). When set to false (role USER), the “Reserve”/“Cancel” buttons disappear from the template (see below)—the calendar remains viewable, but in read-only mode;
  • lines 23–26: [onClicCreneau(creneauAgenda: CreneauAgenda): void { if (!this.peutModifier()) { return; } … }] — this application guard is redundant with the button being hidden in the template (a role USER never sees this button): it nevertheless protects against a programmatic call to this method and documents the intent in the same place as the rest of the logic. Reminder: In any case, the server ([RolesGuard], Chapter 3) remains the only real safeguard—a manually modified [Angular] client could not bypass this restriction;
  • Lines 27–31: [if (creneauAgenda.rv === null) { this.reserver.emit(…); } else { this.annuler.emit(…); }] — if the time slot is available, the user is prompted to book it; if the time slot is already booked, the user is prompted to cancel the existing appointment. This component does not make any HTTP calls itself: it displays received data and notifies its parent of the user’s intentions.

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>
 }

Let’s comment on this code:

  • line 1: [@if (agenda(); as monAgenda) {] — @if and @for are the new flow control syntax in [Angular]: they replace *ngIf and *ngFor, respectively, and are part of the template language itself (no module import required). as monAgenda captures the non-null value in a template variable, avoiding the need to repeat `agenda()`! everywhere afterward;
  • lines 14–18: [@if (creneauAgenda.rv === null) { ... } @else { ... }] — green Bootstrap badge (“Available”/“Free”) or gray (patient’s name), depending on the slot’s status;
  • line 21: [@if (peutModifier()) {] a new feature introduced by authentication: the “Reserve”/“ Cancel” button doesn’t even exist in DOM for a USER role (peutModifier() is then false)—it’s not just grayed out or disabled, it’s completely absent;
  • line 24: [{{ creneauAgenda.rv === null ? (‘AGENDA.BOOK’ | translate) : (‘AGENDA.CANCEL_APPOINTMENT’ | translate) }}] — a pipe can be used inside any template expression, including a ternary operator, as long as each branch is properly parenthesized.

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

The (modal) window that allows you to select a patient to book an available slot. Equivalent to Example 9 from the original AngularJS 1.x client.

  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();
   }
 }

Let’s comment on this code:

  • line 12: [readonly ouvert = input.required();] — the component is always present in the parent template ([App]): this entry controls, via [@if] (see the template below), whether it is displayed or not;
  • Lines 26–33: [onClicConfirmer(): void { … this.confirmer.emit({ idClient }); this.idClientSelectionne.set(null); }] — passes the ID of the selected patient to [App], which will then call RdvService.ajouterRv(...); idClientSelectionne is reset for the next time the window is opened;
  • lines 35–38: [onClicFermer(): void { … this.fermer.emit(); }] — if the user cancels, the parent simply closes the window without making a call to the server.

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

This component now uses the actual Bootstrap classes for a modal (.modal, .modal-dialog, .modal-content, .modal-backdrop…), but its visibility is still controlled by [Angular] ([@if] (open())) rather than by Bootstrap’s JavaScript (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>
 }

Let’s break down this code:

  • line 2: [] — the semi-transparent background that darkens the rest of the page — hard-coded here, whereas Bootstrap usually inserts it itself via JavaScript when the modal is displayed;
  • line 4: [<div class=“modal fade show d-block” …>] — d-block replaces the role normally filled by Bootstrap’s JavaScript (adding `display:block` upon opening)—here, it’s [@if] from line 1 that directly fulfills this role;
  • lines 6–10, 12–24, 26–31: [.modal-content / .modal-header / .modal-body / .modal-footer] — the standard structure of a Bootstrap modal (header with title + close button, body, footer with actions);
  • line 9: [[attr.aria-label]=“‘BOOKING_DIALOG.CLOSE_ARIA’ | translate”] — the standard Bootstrap close button (.btn-close), linked to the same event handler as the “Cancel” button in the modal footer (line 27); its accessible text (aria-label) is set via an attribute binding ([attr.xxx]) rather than simple {{ }} interpolation, which is the only possible syntax when the value of an attribute HTML is not static text.

Why not use Bootstrap’s JavaScript? Because Bootstrap and [Angular] would then each manage the same information independently (is the modal open?)—a classic source of subtle bugs in an Angular application. By letting open() (a signal, and thus the state [Angular]) control the display on its own, we have a single source of truth while maintaining the exact visual appearance of a Bootstrap modal. The original document (2014) used a component from the angular-ui-bootstrap library (a “turnkey” modal AngularJS 1.x), already designed according to this same principle: state managed by [Angular], appearance borrowed from Bootstrap.

6.11. The root component [App]: orchestrator and authentication guardian

This component fulfills the role played by the “main controller” of the original AngularJS 1.x application: it maintains the application’s global state and responds to events from the features/ components to call [RdvService] at the right time. Since the addition of authentication, it has taken on a second role: that of a presentation-layer “gatekeeper,” which decides whether to display the login screen or the rest of the 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';
  // ... templates, components (see detailed imports in the file)

 @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)),
      });
  }
 }

Let’s comment on this code:

  • line 12: [imports: [DoctorDayPickerComponent, AgendaComponent, BookingDialogComponent, LoginComponent, TranslatePipe],] — a standalone component declares the components and pipes it uses in its template—[LoginComponent] was added to this list for authentication, and [TranslatePipe] for translation;
  • line 20: [protected readonly auth = inject(AuthService);] — exposed to the template (protected, not private) for the role/name badge and the logout button (see [app.html]);
  • line 21: [protected readonly langue = inject(LanguageService);] — exposed to the template for the two buttons FR/EN in the navigation bar, which are always visible (including on the login screen) unlike the rest of the interface;
  • Line 33: [effect(() => { if (this.auth.estConnecte()) { this.chargerListesInitiales(); } });] — `effect()` re-executes its body every time a signal it monitors changes—in this case, estConnecte(). It also runs once immediately: if a session was already stored in localStorage (page reload), the lists load right at startup, without waiting for a new login;
  • line 40: [private chargerListesInitiales(): void {] — when a user becomes logged in (successful login or restored session), the lists of doctors and clients are loaded once and for all—the client-side equivalent, this time, of what [ApplicationModelService] did when the server started (Chapter 3). Since all server endpoints are now protected by [JwtAuthGuard], it would be pointless anyway (and cause 401 errors) to make this call before logging in;
  • line 51: [onDeconnexion(): void {] — responds to the “Log Out” button: clears the session and all displayed business state (lines 53–59)—without this, a new user logging in afterward would briefly see the previous user’s calendar;
  • Lines 41, 45, etc.: [.pipe(takeUntilDestroyed(this.destroyRef))] — a RxJS subscription (.subscribe(...)) remains active until explicitly unsubscribed; this is a common oversight and a source of memory leaks. This operator automatically unsubscribes as soon as the component is destroyed;
  • line 66: [onDemandeReservation(creneau: CreneauJson): void { this.creneauEnReservation.set(creneau); }] does not yet call the server: it simply opens the reservation window. It is onConfirmerReservation (line 70), triggered by the (confirm) event from [BookingDialogComponent], that actually calls ajouterRv;
  • Lines 78, 89: [this.chargerAgenda();] — after a successful reservation or cancellation, chargerAgenda() is called again: this allows the displayed calendar to immediately reflect the new status, without the user needing to refresh the 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>

Let’s break down this code:

  • line 2: [] — Bootstrap classes for an app header: a colored banner (bg-primary), light text (navbar-dark), and automatic horizontal alignment of its content;
  • line 3: [{{ ‘APP.TITLE’ | translate }}] — first example of the `translate` pipe ([@ngx-translate/core]): instead of hard-coded French text, a key (APP.TITLE) resolved in the dictionary for the current language (public/i18n/fr.json or en.json);
  • lines 6–15: [<div class=“btn-group btn-group-sm” …> … FR … EN … ] — the language selector, always visible (both before and after login, unlike the role badge): two Bootstrap buttons grouped together (btn-group), one of which is highlighted (btn-light rather than btn-outline-light) depending on the current language (langue.langueCourante()), and clicking which calls langue.changerLangue('fr' | 'en');
  • lines 17–23: [@if (auth.estConnecte()) { ... badge + nom + bouton de déconnexion ... }] — this block appears only after logging in; auth.[role]() populates a Bootstrap badge (text-bg-light badge), auth.[nom]() displays the user’s name, and the button text also passes through | translate (key APP.LOGOUT);
  • line 28: [{{ ‘APP.SERVER_URL_LABEL’ | translate }}] — same mechanism as for the title, applied to the label of the “URL from the server” field;
  • lines 27–33: [URL du serveur] — remains displayed both before and after login (useful for pointing to another server, [NestJS], even before attempting to log in);
  • line 39: [@if (!auth.estConnecte()) { <app-login /> } @else { ... }] — the core of the “on-call” role for [App]: as long as the user is not logged in, nothing other than the login screen is displayed—neither the doctor/day selector, nor the calendar, nor the booking window;
  • line 46: [[peutModifier]=“auth.estAdmin()”] — passes control to [AgendaComponent] depending on whether the logged-in user has the right to book/cancel (role ADMIN) or not (role USER, read-only);
  • lines 51–57: [<app-booking-dialog … />] — always present in DOM (as before authentication): its visibility is controlled by its open entry.

Note that the language selector is placed outside the [@if] block (auth.estConnecte()): it must remain usable from the login screen onward, before any JWT token is issued—in fact, the screenshot below (“[Login] screen”) illustrates the switch to English on this very screen.

6.12. Step-by-Step Guide to Using the Application

The following screenshots were taken while preparing this document, using a test copy of the server seeded with demo data (3 doctors, including Ms. Marie PELISSIER; 4 clients, including Brigitte BISTROU; and the two accounts admin/admin and user/user).

6.12.1. 1. Login Screen

Until the user logs in, only the login screen is displayed—the rest of the application (doctor/day selector, calendar) does not yet appear on the page. The two buttons “FR” and “EN” in the header, however, are already usable at this stage—no token is required to change the language:

Image

Login Screen

An attempt with an incorrect password displays an error message without further details (to avoid revealing whether the issue is with the username or the password):

Image

Login error

Clicking on “EN” instantly translates the screen, including this error message if it is displayed at the time the language is changed—a direct consequence of the choice explained earlier in this chapter (section login.component.ts), to store a translation key in erreur() rather than a French text that has already been resolved:

Image

Login screen in English

6.12.2. 2. Logging in as an administrator (role ADMIN)

Once logged in with the admin/admin account, the header displays the role (“ADMIN” badge) and the user’s name, along with a “Log Out” button. The doctor/day selector appears, just as it did before authentication:

Image

Home, once logged in as ADMIN

The English interface remains available once logged in; it then also translates the role badge, label names, and the "Log out" button:

Image

Home page, after logging in to ADMIN, in English

After selecting a doctor and clicking “View Schedule,” each available time slot has a “Book” button, and each booked time slot has a “Cancel” button: the ADMIN role has full access, exactly as it did before roles were added:

Image

Calendar, ADMIN view (Book/Cancel buttons visible)

Clicking “Reserve” opens the Bootstrap modal window, prompting you to select a patient:

Image

Booking window (Bootstrap modal)

After selecting a patient and clicking “Confirm,” the window closes and the calendar refreshes automatically: the booked slot now appears as occupied, with the name of the selected patient:

Image

Calendar after booking

Clicking “Cancel” (when the slot is booked) refreshes the calendar in the same way—the slot returns to “Available”:

Image

Calendar after cancellation

6.12.3. 3. Log in as a user (role USER, read-only)

After logging out, logging in with the user/user account displays a different “USER” badge:

Image

Home screen, once logged in as USER

The calendar remains viewable, but no “Reserve”/“Cancel” buttons appear: the actions column is empty for each time slot. A direct request to the server (for example, using Postman and providing the user’s token) would still receive a 403 Forbidden response—the hiding of the buttons is merely a display convenience; the actual restriction is enforced by [RolesGuard] on the server side (Chapter 3):

Image

Calendar, USER view (read-only, no action buttons)

6.13. What remains out of scope or deferred

The “debug” mode (display of the raw template for the current view) of the original AngularJS 1.x client is a feature deliberately omitted from this port—it will not appear in a later stage of the course (see Chapter 6, conclusion, for the rationale behind this decision). Setting an artificial network delay, however, is indeed postponed to a later stage.