Skip to content

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

Ce chapitre détaille, fichier par fichier, le contenu du dossier [rdvmedecins-react-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-Services (Vue-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 [React], elles sont réunies dans une seule entité, le composant (cf. chapitre précédent) - exactement comme en [Angular], même si la forme diffère (une fonction plutôt qu’une classe) - 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 (ici, le hook useRdvService() et le contexte AuthContext). C’est un principe de conception qui vaut aussi bien pour AngularJS 1.x que pour [Angular] ou [React] - il n’a pas changé.

6.2. Arborescence du projet

Image

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 [React] fonctionnels plutôt qu’en contrôleurs/vues AngularJS 1.x séparés, ou en composants standalone [Angular]. Deux différences structurelles, par rapport à l’arborescence [Angular] de ce cours :

  • le composant racine tient en trois fichiers (App.tsx, App.css, README.md) plutôt que cinq (app.ts, app.html, app.css, app.config.ts, app.routes.ts) : [JSX] fusionne la classe et le template, et [React] n’a pas de fichier de configuration d’application équivalent à app.config.ts (cf. plus loin, « Configuration de l’application ») ;
  • un dossier core/context/ apparaît, absent de la variante [Angular] : il regroupe les hooks personnalisés et les [Context] [React] qui jouent le rôle des services injectables ([SettingsService], [AuthService], [LanguageService]) de la variante [Angular] - ce mécanisme (hooks + [Context]) a été présenté au chapitre précédent, avec un exemple générique (cf. chapitre précédent, « Partager de la logique »).

[Bootstrap] 5 est utilisé dans toutes les vues (importé dans main.tsx, cf. plus loin) - une évolution directe de Bootstrap 3, déjà utilisé par le document original, inchangée par rapport à la variante [Angular] de ce cours.

6.3. Fichiers de configuration du projet

Image

6.3.1. package.json

{
  "name": "rdvmedecins-react-client",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "start": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "i18next": "^25.0.0",
    "i18next-http-backend": "^3.0.0",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-i18next": "^15.0.0",
    "bootstrap": "^5.3.3"
  },
  "devDependencies": {
    "@types/react": "^19.1.0",
    "@types/react-dom": "^19.1.0",
    "@vitejs/plugin-react": "^5.0.0",
    "typescript": "~5.9.0",
    "vite": "^6.4.0"
  }
}

Commentons ce code :

  • ligne 5 : "type": "module", — indique à Node que ce projet utilise nativement les modules ECMAScript (import/export), sans transformation préalable - un réglage qu’un projet [Angular] CLI définit lui aussi, implicitement, dans son propre outillage ;
  • lignes 6-11 : "scripts": { … } — les commandes [npm run <nom>] disponibles. [start] (comme dev) lance le serveur de développement [Vite] avec rechargement automatique à chaque modification d’un fichier source ([Vite] appelle ce mécanisme Hot Module Replacement) - c’est la commande utilisée tout au long de ce document, sur le port 4200 (cf. [vite.config.ts] ci-dessous), le même port que la variante [Angular] de ce cours ;
  • lignes 12-19 : "dependencies": { … } — les paquets nécessaires à l’exécution de l’application dans le navigateur : [react]/[react-dom] (le cœur de [React] et son intégration au DOM du navigateur), [Bootstrap] (feuille de style uniquement), et - apportés par ce chapitre - [i18next]/[i18next-http-backend]/[react-i18next], la bibliothèque tierce utilisée pour la traduction FR/EN de l’interface (cf. i18n.ts et core/context/language.context.tsx plus loin). À la différence de la variante [Angular] de ce cours, il n’y a ni équivalent de [@angular/router] (aucun routeur, cf. chapitre précédent), ni de RxJS ([fetch] renvoie directement une Promise, cf. chapitre précédent) ;
  • lignes 19-25 : "devDependencies": { … } — les paquets nécessaires uniquement pendant le développement, jamais embarqués dans le bundle final livré au navigateur : les types TypeScript de [React] ([@types/react], [@types/react-dom] - [React] lui-même est écrit en JavaScript, pas en TypeScript, contrairement à [Angular]), [@vitejs/plugin-react] (la transformation du [JSX] en JavaScript, et le Fast Refresh en développement), TypeScript, et [Vite] lui-même.

Contrairement au serveur [NestJS] (module [commonjs], cf. chapitre 3), ce projet fonctionne en modules ECMAScript natifs (import/export) - exactement comme la variante [Angular] de ce cours ("module": "preserve" dans tsconfig.json, cf. ci-dessous).

6.3.2. vite.config.ts

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 4200,
  },
});

Commentons ce code :

  • ligne 5 : plugins: [react()], — active la transformation du [JSX]/[TSX] et le rechargement à chaud - l’équivalent, pour [React], de ce que @angular/build:dev-server fait pour [Angular] en arrière-plan (cf. angular.json de la variante [Angular] de ce cours) ;
  • lignes 6-8 : server: { port: 4200, }, — fixe le port du serveur de développement à 4200, le même que [ng serve] côté [Angular] - un choix purement pédagogique, pour que les deux variantes de ce cours restent interchangeables sans changer d’habitude.

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é [Angular] de ce cours, c’est [angular.json] qui en joue le rôle ; côté [NestJS] (chapitre 3), c’est [nest-cli.json].

6.3.3. tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"]
}

Commentons ce code :

  • ligne 5 : "jsx": "react-jsx", — indique au compilateur TypeScript comment transformer le [JSX] ; [react-jsx] est le réglage moderne (depuis React 17), qui évite d’avoir à écrire [import React from 'react'] dans chaque fichier .tsx utilisant du [JSX] - une petite simplification par rapport aux tout premiers projets [React] ;
  • ligne 6 : "strict": true, — comme le [tsconfig.json] de la variante [Angular] de ce cours, le mode strict complet de TypeScript est actif : chaque variable doit avoir un type déterminable, chaque null/undefined doit être traité explicitement ;
  • lignes 7-8 : "noUnusedLocals": true, "noUnusedParameters": true, — signale à la compilation toute variable ou tout paramètre déclaré mais jamais utilisé - un réglage que la variante [Angular] de ce cours n’active pas explicitement (le compilateur [Angular] a ses propres vérifications, différentes, via strictTemplates) ;
  • ligne 14 : "noEmit": true — TypeScript ne sert ici qu’à la VÉRIFICATION de types ([npm run build] lance d’abord tsc -b, qui échoue si une erreur de type existe) : c’est [Vite] (via esbuild) qui produit réellement le JavaScript exécuté par le navigateur, pas le compilateur TypeScript lui-même - une différence notable avec le compilateur [Angular] (ngc), qui produit lui-même le JavaScript final.

Là où la variante [Angular] de ce cours sépare [tsconfig.json] (socle commun) et [tsconfig.app.json] (via le mécanisme des project references), ce projet [React], plus petit, se contente d’un seul fichier - les vérifications de types des templates ([Angular] strictTemplates) n’ayant de toute façon pas d’équivalent ici : c’est le compilateur TypeScript lui-même qui vérifie le [JSX], au même titre que n’importe quel autre fichier .tsx.

6.4. Démarrage de l’application

Image

6.4.1. index.html

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
    <title>RdvMedecins - client React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Commentons ce code :

  • ligne 2 : <html lang="fr"> — comme côté [Angular], cette page HTML étant statique (chargée par le navigateur AVANT même le démarrage de l’application [React]), elle ne peut pas refléter tout de suite un choix de langue fait plus tard par l’utilisateur ;
  • ligne 10 : <div id="root"></div> — l’unique élément que cette page contient au départ, et que [React] remplit entièrement une fois démarré (cf. [main.tsx] ci-dessous) - l’équivalent exact de <app-root></app-root> côté [Angular], et de [ng-app="rdvmedecinsApp"] sur la balise <html> du document original (AngularJS 1.x, 2014) ;
  • ligne 11 : <script type="module" src="/src/main.tsx"></script> — première différence notable avec la variante [Angular] de ce cours : c’est index.html qui référence directement main.tsx (une convention propre à [Vite]), plutôt que l’inverse (un bundle JavaScript déjà compilé, injecté dans la page par le processus de build [Angular]). En développement, [Vite] transforme et sert ce fichier .tsx à la volée ; en production (npm run build), il est bien sûr compilé au préalable.

Il n’y a, comme côté [Angular], 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.2. src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { SettingsProvider } from './app/core/context/settings.context';
import { AuthProvider } from './app/core/context/auth.context';
import { LanguageProvider } from './app/core/context/language.context';
import { App } from './app/App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import './i18n';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SettingsProvider>
      <AuthProvider>
        <LanguageProvider>
          <App />
        </LanguageProvider>
      </AuthProvider>
    </SettingsProvider>
  </StrictMode>,
);

Commentons ce code :

  • ligne 11 : createRoot(document.getElementById('root')!).render(…) — l’équivalent direct de bootstrapApplication(App, appConfig) côté [Angular] : démarre l’application dans l’élément #root d’index.html. Le point d’exclamation (!) indique à TypeScript qu’on est certain que cet élément existe (il est défini ligne 10 d’[index.html]) ;
  • lignes 13-19 : <SettingsProvider><AuthProvider><LanguageProvider><App /></LanguageProvider></AuthProvider></SettingsProvider> — les trois Provider de core/context/ (cf. plus loin) enveloppent le composant racine [App] : c’est ce qui rend useSettings(), useAuth() et useLangue() utilisables depuis n’importe quel composant descendant - l’équivalent des trois provideXxx() de [app.config.ts] côté [Angular] (providers: [...]), mais exprimé ici comme un emboîtement de composants plutôt que comme une liste ;
  • ligne 7 : import 'bootstrap/dist/css/bootstrap.min.css';[Vite] permet d’importer directement une feuille de style depuis un fichier TypeScript ; côté [Angular], c’est angular.json (tableau styles: [...]) qui joue ce rôle - même résultat, mécanisme différent ;
  • ligne 9 : import './i18n'; — importé pour son seul effet de bord (l’initialisation d’[i18next], cf. i18n.ts plus loin) : rien n’est utilisé de cet import, mais l’exécuter est nécessaire avant que [useTranslation()]] fonctionne dans les composants.

<StrictMode> est un composant propre à [React] qui n’affecte que le développement (aucun effet en production) : il aide à détecter certains effets de bord mal écrits en exécutant deux fois certains rendus - sans équivalent direct côté [Angular], dont le compilateur détecte plutôt ce type d’erreurs à la compilation.

6.4.3. src/i18n.ts

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    lng: 'fr',
    fallbackLng: 'fr',
    interpolation: {
      escapeValue: false,
    },
    backend: {
      loadPath: '/i18n/{{lng}}.json',
    },
  });

export default i18n;

Commentons ce code :

  • ligne 6 : .use(HttpBackend) — charge chaque dictionnaire par une requête HTTP GET, sur l’URL formée par [backend.loadPath] (ligne 15) : [/i18n/fr.json] ou [/i18n/en.json], EXACTEMENT la même URL que celle construite par [TranslateHttpLoader] côté [Angular] (prefix: '/i18n/', suffix: '.json'), pour que [public/i18n/fr.json] et [public/i18n/en.json] (cf. plus loin) puissent être repris tels quels, sans aucune modification ;
  • ligne 7 : .use(initReactI18next) — branche [i18next] sur [React] : fournit le hook useTranslation() utilisé dans chaque composant de features/, et le [Context] React (interne à la bibliothèque, invisible ici) qui déclenche un nouveau rendu de toute l’interface quand la langue change - l’équivalent de ce que fait, côté [Angular], le décorateur [TranslatePipe] combiné à [provideTranslateService(...)] ;
  • ligne 9 : lng: 'fr', — langue de démarrage ; le hook [useLangue()] (cf. core/context/language.context.tsx plus loin) la remplace aussitôt par le choix mémorisé dans localStorage, s’il y en a un - exactement comme lang: 'fr' dans [app.config.ts] côté [Angular] ;
  • lignes 11-13 : interpolation: { escapeValue: false, },[React] échappe déjà lui-même le HTML (protection contre les failles XSS) : inutile qu’[i18next] le refasse une seconde fois.

Dans le document original (2014), le client AngularJS 1.x s’appuyait sur la bibliothèque angular-translate pour ce même besoin - l’ancêtre, dans l’écosystème AngularJS 1.x, de [@ngx-translate/core] (variante [Angular] de ce cours) puis, ici, d’[i18next]. Les DEUX bibliothèques modernes ([ngx-translate] côté [Angular], [i18next] côté [React]) chargent des dictionnaires JSON par une simple requête HTTP GET, avec le même schéma « un fichier par langue, une section par écran ».

6.4.4. src/index.css

1
2
3
4
5
6
7
body {
  background-color: #f5f7fa;
}

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

Commentons ce code :

  • ligne 1 : body { background-color: #f5f7fa; } — repris à l’identique de src/styles.css côté [Angular] : 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-7 : .creneau-libre:hover — appliquée aux lignes du tableau d’agenda représentant un créneau libre (cf. Agenda.tsx, className={creneauAgenda.rv === null ? 'creneau-libre' : undefined} plus loin) : le fond se teinte légèrement en vert au survol. Le curseur en forme de main (cursor: pointer côté [Angular]) est ici directement porté par le bouton « Réserver » lui-même (un vrai <button>), plutôt que par la ligne entière du tableau.

Ce fichier contient les styles globaux, par opposition aux styles « locaux » définis dans le fichier .css de chaque composant (login.css, agenda.css…), qui ne s’appliquent qu’au [JSX] de ce composant précis - le même principe qu’en [Angular] (fichiers .css par composant). App.css, le fichier de styles du composant racine, est resté vide : [Bootstrap] et index.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 :

  • ce fichier est un copier-coller strict du fichier de même nom de la variante [Angular] de ce cours : les interfaces TypeScript décrivant les données échangées avec le serveur ne dépendent d’aucune bibliothèque d’interface, [React] ou [Angular] - c’est du TypeScript « pur », qui correspond terme à terme à la classe [Reponse]<T> et aux entités [TypeORM] du serveur [NestJS] (chapitre 3) ;
  • [Client] désigne ici, comme côté [Angular] et dans le document original, un PATIENT du cabinet médical - à ne pas confondre avec « client HTTP » ;
  • Role / LoginResultat — copies conformes des types serveur du même nom (src/entities/user.entity.ts, auth/login-resultat.model.ts, chapitre 3).

En AngularJS 1.x (2014), le JavaScript pur ne permettait pas de décrire ainsi la forme attendue des données. Avec TypeScript - utilisé aussi bien par [Angular] que par [React] -, une faute de frappe sur un nom de champ est signalée avant même d’exécuter le programme, dès la compilation.

6.6. La couche core/context

Image

Ce dossier n’a pas d’équivalent dans la variante [Angular] de ce cours (cf. chapitre précédent, « Partager de la logique ») : il regroupe les trois [Context] [React] qui jouent, ici, le rôle tenu par les services injectables ([SettingsService], [AuthService], une partie de [LanguageService]) côté [Angular].

6.6.1. src/app/core/context/settings.context.tsx

import { createContext, useContext, useState, type ReactNode } from 'react';

interface SettingsContextValue {
  apiBaseUrl: string;
  setApiBaseUrl: (url: string) => void;
}

const SettingsContext = createContext<SettingsContextValue | null>(null);

export function SettingsProvider({ children }: { children: ReactNode }) {
  const [apiBaseUrl, setApiBaseUrl] = useState('http://localhost:8080');
  return (
    <SettingsContext.Provider value={{ apiBaseUrl, setApiBaseUrl }}>
      {children}
    </SettingsContext.Provider>
  );
}

export function useSettings(): SettingsContextValue {
  const contexte = useContext(SettingsContext);
  if (contexte === null) {
    throw new Error('useSettings() doit être appelé sous un <SettingsProvider>');
  }
  return contexte;
}

Commentons ce code :

  • ligne 11 : const [apiBaseUrl, setApiBaseUrl] = useState('http://localhost:8080'); — remplace exactement [readonly apiBaseUrl = signal('http://localhost:8080');]] de [SettingsService] côté [Angular] : la même valeur par défaut, la même possibilité de la modifier depuis le champ « URL du serveur » de la vue d’accueil du document original ;
  • lignes 12-16 : <SettingsContext.Provider value={{ apiBaseUrl, setApiBaseUrl }}>{children}</SettingsContext.Provider> — publie la paire (valeur, setter) à tous les descendants, exactement le rôle que jouait [providedIn: 'root'] côté [Angular] ;
  • lignes 19-25 : export function useSettings(): SettingsContextValue { — le hook que les composants appellent (const settings = useSettings();), équivalent direct de inject(SettingsService).

6.6.2. src/app/core/context/auth.context.tsx

Portage d’[AuthService] : gère la connexion de l’utilisateur, appelle [POST /login], garde le jeton JWT reçu (et l’identité/rôle qui l’accompagnent), et le fait survivre à un rechargement de page grâce à [localStorage]. C’est le seul endroit de l’application, avec [useRdvService()] (cf. plus loin), qui parle HTTP au serveur.

import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import type { LoginResultat, Reponse, Role } from '../models/rdv.models';
import { useSettings } from './settings.context';

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

const CLE_STOCKAGE = 'rdvmedecins.session';

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

interface AuthContextValue {
  estConnecte: boolean;
  login: string | null;
  nom: string | null;
  role: Role | null;
  estAdmin: boolean;
  accessToken: string | null;
  seConnecter: (login: string, password: string) => Promise<LoginResultat>;
  seDeconnecter: () => void;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const { apiBaseUrl } = useSettings();
  const [session, setSession] = useState<SessionStockee | null>(lireSessionStockee);

  async function seConnecter(login: string, password: string): Promise<LoginResultat> {
    const reponseHttp = await fetch(`${apiBaseUrl}/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ login, password }),
    });
    const enveloppe = (await reponseHttp.json()) as Reponse<LoginResultat>;
    if (enveloppe.status !== 0 || enveloppe.data === null) {
      throw new Error('Échec de connexion');
    }
    const resultat = enveloppe.data;
    setSession(resultat);
    localStorage.setItem(CLE_STOCKAGE, JSON.stringify(resultat));
    return resultat;
  }

  function seDeconnecter(): void {
    setSession(null);
    localStorage.removeItem(CLE_STOCKAGE);
  }

  const value = useMemo<AuthContextValue>(
    () => ({
      estConnecte: session !== null,
      login: session?.login ?? null,
      nom: session?.nom ?? null,
      role: session?.role ?? null,
      estAdmin: session?.role === 'ADMIN',
      accessToken: session?.accessToken ?? null,
      seConnecter,
      seDeconnecter,
    }),
    [session, apiBaseUrl],
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {
  const contexte = useContext(AuthContext);
  if (contexte === null) {
    throw new Error('useAuth() doit être appelé sous un <AuthProvider>');
  }
  return contexte;
}

Commentons ce code :

  • lignes 14-21 : function lireSessionStockee(): SessionStockee | null { — protégée par un try/catch, exactement comme côté [Angular] : [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 ;
  • ligne 35 : const [session, setSession] = useState<SessionStockee | null>(lireSessionStockee);[useState(fonction)] n’exécute cette fonction qu’UNE SEULE fois, au tout premier rendu (pas à chaque rendu) : exactement le rôle que jouait l’initialiseur [signal(lireSessionStockee())] côté [Angular]. Si l’utilisateur recharge la page après s’être connecté, il reste donc connecté ;
  • lignes 40-54 : async function seConnecter(login: string, password: string): Promise<LoginResultat> { — équivalent de [POST /login, corps JSON { login, password } ;] à la différence d’[AuthService.seConnecter()] côté [Angular] (qui renvoie un Observable et enregistre la session dans un opérateur tap() séparé), la version [fetch]/async-await enchaîne naturellement les étapes de haut en bas, sans opérateur dédié ;
  • lignes 56-59 : function seDeconnecter(): void { — purement locale (pas d’appel serveur) : un jeton JWT ne se « révoque » pas côté serveur dans ce portage, il expire de lui-même après JWT_EXPIRES_IN (cf. serveur .env, chapitre 3) ;
  • lignes 61-73 : const value = useMemo<AuthContextValue>(() => ({ … }), [session, apiBaseUrl]);[useMemo]() évite de reconstruire un nouvel objet value à chaque rendu de [AuthProvider] quand ni [session] ni [apiBaseUrl] n’ont changé : sans lui, tout composant utilisant [useAuth()] serait redessiné inutilement à chaque rendu de [AuthProvider] - un souci de performance qui n’a pas d’équivalent direct côté [Angular], où computed() ne recalcule de toute façon que ce qui dépend réellement d’un signal modifié.

6.6.3. src/app/core/context/language.context.tsx

Portage d’une partie de [LanguageService] : centralise le changement de langue (français/anglais) de l’interface, par-dessus le hook [useTranslation()] fourni par [react-i18next].

// src/app/core/context/language.context.tsx

import { createContext, useContext, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

const CLE_STOCKAGE = 'rdvmedecins.langue';

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

interface LanguageContextValue {
  langueCourante: Langue;
  changerLangue: (langue: Langue) => void;
}

const LanguageContext = createContext<LanguageContextValue | null>(null);

export function LanguageProvider({ children }: { children: ReactNode }) {
  const { i18n } = useTranslation();

  useEffect(() => {
    const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null;
    if (langueMemorisee && langueMemorisee !== i18n.language) {
      i18n.changeLanguage(langueMemorisee);
      document.documentElement.lang = langueMemorisee;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function changerLangue(langue: Langue): void {
    localStorage.setItem(CLE_STOCKAGE, langue);
    i18n.changeLanguage(langue);
    document.documentElement.lang = langue;
  }

  const value: LanguageContextValue = {
    langueCourante: i18n.language as Langue,
    changerLangue,
  };

  return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}

export function useLangue(): LanguageContextValue {
  const contexte = useContext(LanguageContext);
  if (contexte === null) {
    throw new Error('useLangue() doit être appelé sous un <LanguageProvider>');
  }
  return contexte;
}

Commentons ce code :

  • ligne 18 : const { i18n } = useTranslation();[react-i18next] expose déjà l’instance i18n (et sa langue courante, i18n.language) via ce hook : pas besoin d’en recréer un état séparé, contrairement à [SettingsContext]/[AuthContext] ; c’est la même idée que côté [Angular], où LanguageService republie simplement this.translate.currentLang sous un autre nom ;
  • lignes 20-27 : useEffect(() => { … }, []); — un tableau de dépendances vide ([]) signifie « n’exécuter cet effet qu’une seule fois, au montage du composant » - l’équivalent direct du constructor() de [LanguageService] côté [Angular] : 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 [i18n.ts] (le français) ;
  • lignes 29-33 : function changerLangue(langue: Langue): void { — appelée par les deux boutons FR/EN de la barre de navigation (cf. [App.tsx] plus loin) ; [i18n.changeLanguage(...)] charge (au besoin) le fichier [public/i18n/<langue>.json] correspondant, puis bascule la langue courante - contrairement à [translate.use(langue)] côté [Angular] (un Observable, cf. chapitre précédent), c’est ici une Promise que le code choisit de ne pas attendre (elle se résout très vite, le fichier étant en cache après le tout premier chargement) ;
  • ligne 32 : document.documentElement.lang = langue; — met à jour l’attribut <html lang="..."> de la page (utile pour l’accessibilité), qu’aucune des deux bibliothèques ne gère elle-même puisque [index.html] est une page statique, chargée avant même le démarrage de l’application - exactement le même besoin, et la même solution, que côté [Angular].

É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] ([Angular]) puis d’[i18next] ([React]).

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

Image

[i18next-http-backend] (configuré dans i18n.ts, cf. plus haut) 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). [react-i18next] résout ensuite chaque clé ("LOGIN.TITLE", "AGENDA.FREE"…) via le hook [useTranslation() (fonction t(...))] employé dans tous les composants de features/ (cf. plus loin dans ce chapitre).

6.7.1. public/i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins - portage NestJS / React",
    "SERVER_URL_LABEL": "URL du serveur",
    "LOGOUT": "Se déconnecter"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "...": "..."
  },
  "DOCTOR_DAY_PICKER": { "...": "..." },
  "AGENDA": { "...": "..." },
  "BOOKING_DIALOG": { "...": "..." }
}

Commentons ce code :

  • ce fichier est repris tel quel de la variante [Angular] de ce cours (mêmes clés, mêmes textes), à une seule exception près : la ligne 3, ["TITLE"], qui mentionne désormais [React] plutôt qu’[Angular] - la seule différence entre les deux clients touchant réellement un texte affiché à l’utilisateur ;
  • un objet JSON imbriqué, une section par composant (APP, LOGIN, DOCTOR_DAY_PICKER, AGENDA, BOOKING_DIALOG) ; [i18next] l’aplatit en un dictionnaire à plat, où chaque clé complète ("LOGIN.TITLE") est formée en joignant le chemin par des points - exactement comme [TranslateService] côté [Angular].

6.7.2. public/i18n/en.json

1
2
3
4
5
6
{
  "APP": {
    "TITLE": "RdvMedecins - NestJS / React port",
    "...": "..."
  }
}

Commentons ce code :

  • ligne 3 : "TITLE": "RdvMedecins - NestJS / React port", — seul le sous-titre change : « [RdvMedecins] » reste tel quel dans les deux langues, c’est le nom propre de l’application.

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. Ce qui n’est PAS traduit reste inchangé par rapport à la variante [Angular] de ce cours : les messages d’erreur renvoyés par le serveur [NestJS] restent en français quelle que soit la langue choisie côté client, et les noms de médecins/patients viennent directement des données de la base.

6.8. La couche core/interceptors

Image

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

[React], n’étant qu’une bibliothèque d’interface, n’a pas de notion d’intercepteur HTTP intégrée comme [HttpClient]/withInterceptors([...]) côté [Angular]. Ce fichier reconstruit la même idée sous la forme d’un hook qui encapsule [fetch] : c’est l’équivalent moderne des intercepteurs $http d’AngularJS 1.x déjà rencontrés dans le document original (chapitre « Exemple 6 »).

import { useAuth } from '../context/auth.context';

export function useAuthFetch() {
  const auth = useAuth();

  return async function authFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
    const headers = new Headers(init.headers);
    if (auth.accessToken) {
      headers.set('Authorization', `Bearer ${auth.accessToken}`);
    }
    const reponse = await fetch(input, { ...init, headers });
    if (reponse.status === 401) {
      auth.seDeconnecter();
    }
    return reponse;
  };
}

Commentons ce code :

  • ligne 3 : export function useAuthFetch() { — contrairement à [authInterceptor] côté [Angular] (une fonction enregistrée une fois pour toutes dans [app.config.ts], qui s’applique ensuite automatiquement à chaque appel [HttpClient]), [useAuthFetch()] est un hook que chaque appelant (ici, useRdvService(), cf. plus loin) doit explicitement appeler et utiliser à la place de fetch() - une différence de mécanisme entre les deux bibliothèques, pas de comportement final ;
  • lignes 6-10 : if (auth.accessToken) { headers.set('Authorization', …); } — l’équivalent direct de [request.clone({ setHeaders: { Authorization: … } })] côté [Angular]. Contrairement à une requête [HttpClient] (immuable), les options de [fetch] sont un simple objet JavaScript : pas besoin d’en fabriquer une copie « clonée », il suffit de construire l’objet [headers] avant l’appel ;
  • lignes 12-14 : if (reponse.status === 401) { auth.seDeconnecter(); } — même principe que côté [Angular] (catchError + HttpErrorResponse.status === 401) : un 401 en cours de session signifie que le jeton n’est plus valide, on déconnecte proprement le client ; ici, [fetch] ne rejette pas sa Promise pour un statut HTTP en erreur (contrairement à un Observable [HttpClient]) - c’est pourquoi ce test porte directement sur [reponse.status], sans try/catch ;
  • ligne 15 : return reponse; — la réponse (potentiellement 401) est malgré tout renvoyée telle quelle à l’appelant, qui reste responsable d’en tirer les conséquences (cf. useRdvService(), extraire<T>(), plus loin) - l’intercepteur ne doit pas cacher l’erreur au code appelant.

On pourrait ajouter [Authorization: Bearer ...] à la main dans chacune des méthodes de [useRdvService()]. Un hook partagé évite cette répétition, pour le même bénéfice qu’un intercepteur [Angular] : la question « comment authentifie-t-on une requête ? » est répondue à un seul endroit.

6.9. La couche core/services

Image

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

Portage de [RdvService] : le SEUL endroit de l’application (avec [core/context/auth.context.tsx] pour la connexion elle-même) qui parle HTTP avec le serveur [NestJS]. C’est l’équivalent direct du service [dao] présenté au chapitre « Exemple 6 : les services HTTP » du document original (lequel utilisait le service AngularJS $http).

import { useCallback } from 'react';
import { useAuthFetch } from '../interceptors/auth.interceptor';
import { useSettings } from '../context/settings.context';
import type { AgendaMedecinJour, Client, Medecin, Reponse, RvJson } from '../models/rdv.models';

async function extraire<T>(reponseHttp: Response): Promise<T> {
  const enveloppe = (await reponseHttp.json()) as Reponse<T>;
  if (enveloppe.status !== 0) {
    throw new Error(`Le serveur a répondu avec le statut d'erreur ${enveloppe.status}`);
  }
  return enveloppe.data as T;
}

export function useRdvService() {
  const authFetch = useAuthFetch();
  const { apiBaseUrl } = useSettings();

  const getAllMedecins = useCallback(async (): Promise<Medecin[]> => {
    const reponseHttp = await authFetch(`${apiBaseUrl}/getAllMedecins`);
    return extraire<Medecin[]>(reponseHttp);
  }, [authFetch, apiBaseUrl]);

  const getAllClients = useCallback(async (): Promise<Client[]> => {
    const reponseHttp = await authFetch(`${apiBaseUrl}/getAllClients`);
    return extraire<Client[]>(reponseHttp);
  }, [authFetch, apiBaseUrl]);

  const getAgendaMedecinJour = useCallback(
    async (idMedecin: number, jour: string): Promise<AgendaMedecinJour> => {
      const reponseHttp = await authFetch(
        `${apiBaseUrl}/getAgendaMedecinJour/${idMedecin}/${jour}`,
      );
      return extraire<AgendaMedecinJour>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  const ajouterRv = useCallback(
    async (jour: string, idClient: number, idCreneau: number): Promise<RvJson> => {
      const reponseHttp = await authFetch(`${apiBaseUrl}/ajouterRv`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jour, idClient, idCreneau }),
      });
      return extraire<RvJson>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  const supprimerRv = useCallback(
    async (idRv: number): Promise<void> => {
      const reponseHttp = await authFetch(`${apiBaseUrl}/supprimerRv`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ idRv }),
      });
      await extraire<null>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  return { getAllMedecins, getAllClients, getAgendaMedecinJour, ajouterRv, supprimerRv };
}

Commentons ce code :

  • ligne 6 : async function extraire<T>(reponseHttp: Response): Promise<T> { — plutôt que de répéter, dans chacune des fonctions publiques, le test « si status !== 0, c’est une erreur », on le centralise une seule fois ici - exactement le rôle de [RdvService.extraire()] côté [Angular], mais déclarée en dehors du hook (une fonction ordinaire, pas besoin qu’elle soit recréée à chaque rendu) ;
  • ligne 14 : export function useRdvService() { — un hook personnalisé qui se lit et s’utilise exactement comme [inject(RdvService)] côté [Angular] : on l’appelle une fois en haut d’un composant, puis on utilise les fonctions qu’il renvoie ;
  • lignes 18-21 : const getAllMedecins = useCallback(async () => { … }, [authFetch, apiBaseUrl]);[useCallback]() mémorise cette fonction entre deux rendus, tant que [authFetch] et [[apiBaseUrl] ne changent pas : une optimisation nécessaire ici parce que ce hook est rappelé à chaque rendu du composant qui l’utilise (App.tsx) - sans elle, une nouvelle fonction [getAllMedecins] serait recréée à chaque rendu, ce qui redéclencherait inutilement certains effets (useEffect(…, [rdv]), cf. App.tsx plus loin) ; ce détail de plomberie n’a pas d’équivalent côté [Angular], où [RdvService] n’existe qu’une seule fois pour toute l’application (providedIn: 'root') ;
  • chaque fonction 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 (getAllMedecins, ajouterRv, supprimerRv…), ce qui facilite la lecture croisée serveur/client, exactement comme côté [Angular].

Contrairement à [AuthService.seConnecter()] (chapitre précédent), aucune des méthodes de ce hook n’a besoin de mémoriser quoi que ce soit après l’appel : c’est le composant appelant (App.tsx) qui décide quoi faire du résultat (setMedecins(...), setAgenda(...)…) - la même séparation des responsabilités que côté [Angular], où [RdvService] ne connaît rien de l’état affiché à l’écran.

6.10. La couche features : les quatre composants

Image

6.10.1. src/app/features/login/Login.tsx

Portage de [login.component.ts/.html] : l’écran de connexion, équivalent de la vue [login.html] du client AngularJS 1.x original.

import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../core/context/auth.context';
import './login.css';

export function Login() {
  const { t } = useTranslation();
  const auth = useAuth();

  const [login, setLogin] = useState('');
  const [password, setPassword] = useState('');
  const [enCours, setEnCours] = useState(false);
  const [erreur, setErreur] = useState<string | null>(null);

  async function onValider(evenement?: FormEvent): Promise<void> {
    evenement?.preventDefault();
    if (login.trim() === '' || password === '') {
      return;
    }
    setErreur(null);
    setEnCours(true);
    try {
      await auth.seConnecter(login, password);
      setEnCours(false);
    } catch {
      setEnCours(false);
      setErreur('LOGIN.ERROR');
    }
  }

  return (
    <div className="row justify-content-center">
      <div className="col-sm-8 col-md-6 col-lg-4">
        <form className="card p-4" onSubmit={onValider}>
          <h5 className="card-title mb-3">{t('LOGIN.TITLE')}</h5>
          {erreur && (
            <div className="alert alert-danger py-2" role="alert">
              {t(erreur)}
            </div>
          )}
          <div className="mb-3">
            <label className="form-label" htmlFor="input-login">
              {t('LOGIN.LOGIN_LABEL')}
            </label>
            <input id="input-login" type="text" className="form-control"
              value={login} onChange={(e) => setLogin(e.target.value)} />
          </div>
          <div className="mb-3">
            <label className="form-label" htmlFor="input-password">
              {t('LOGIN.PASSWORD_LABEL')}
            </label>
            <input id="input-password" type="password" className="form-control"
              value={password} onChange={(e) => setPassword(e.target.value)} />
          </div>
          <button type="submit" className="btn btn-primary w-100" disabled={enCours}>
            {enCours ? t('LOGIN.SUBMITTING') : t('LOGIN.SUBMIT')}
          </button>
        </form>
      </div>
    </div>
  );
}

Commentons ce code :

  • ligne 6 : export function Login() { — un composant standalone [Angular] déclare lui-même, dans son décorateur [@Component], les pipes qu’il utilise (imports: [TranslatePipe]) ; un composant [React] n’a rien d’équivalent à déclarer : [useTranslation()]] (ligne 7) est simplement importé et appelé, comme n’importe quelle autre fonction ;
  • lignes 10-13 : const [login, setLogin] = useState(''); … — état purement local au formulaire (ce que l’utilisateur est en train de taper), l’équivalent des [signal()] locaux du composant [Angular] ;
  • lignes 15-16 : async function onValider(evenement?: FormEvent): Promise<void> { evenement?.preventDefault();différence délibérée avec la variante [Angular] de ce cours : plutôt que d’écouter (keyup.enter) sur chaque champ séparément, ce composant utilise un véritable élément <form> (ligne 32) et son événement [onSubmit] natif - à la fois la touche Entrée et un clic sur le bouton « Se connecter » (type="submit") déclenchent alors [onValider()], sans code supplémentaire ; [evenement?.preventDefault()] empêche le comportement par défaut du navigateur (recharger la page), qui casserait le principe même d’une Single Page Application ;
  • lignes 23-24 : await auth.seConnecter(login, password); setEnCours(false); — pas d’autre traitement à faire ici : [AuthContext] a déjà mémorisé la session (état + localStorage) - c’est App.tsx, qui lit useAuth().estConnecte, qui réagira en cessant d’afficher <Login /> ;
  • lignes 24-27 : catch { … setErreur('LOGIN.ERROR'); } — un couple login/mot de passe invalide fait échouer [seConnecter()] avec une exception JavaScript (levée par [auth.context.tsx], cf. plus haut) : on affiche 'LOGIN.ERROR' à l’utilisateur, sans chercher à distinguer un login inexistant d’un mauvais mot de passe - exactement le même choix que côté [Angular]. Mémoriser une clé de traduction plutôt qu’un texte déjà résolu a le même avantage que côté [Angular] : si l’utilisateur bascule de langue pendant que ce message est affiché, il se retraduit tout seul (t(erreur), ligne 37, réévalué à chaque rendu).

Ce composant réunit dans UN SEUL fichier .tsx ce qu’[Angular] sépare en deux (login.component.ts pour la logique, login.component.html pour le template) : le [JSX] retourné par la fonction Login() EST le template, mélangé au code TypeScript qui le pilote. Il n’y a donc pas de fichier .html distinct à commenter séparément, contrairement au chapitre équivalent de la variante [Angular] de ce cours.

6.10.2. src/app/features/doctor-day-picker/DoctorDayPicker.tsx

Portage de [doctor-day-picker.component.ts/.html] : 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 { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { Medecin } from '../../core/models/rdv.models';
import './doctor-day-picker.css';

interface DoctorDayPickerProps {
  medecins: Medecin[];
  onRechercher: (criteres: { idMedecin: number; jour: string }) => void;
}

export function DoctorDayPicker({ medecins, onRechercher }: DoctorDayPickerProps) {
  const { t } = useTranslation();
  const [idMedecinSelectionne, setIdMedecinSelectionne] = useState<number | null>(null);
  const [jourSelectionne, setJourSelectionne] = useState<string>(
    new Date().toISOString().slice(0, 10),
  );

  function onChangementMedecin(evenement: ChangeEvent<HTMLSelectElement>): void {
    const valeur = evenement.target.value;
    setIdMedecinSelectionne(valeur === '' ? null : Number(valeur));
  }

  function onClicRechercher(): void {
    if (idMedecinSelectionne === null) {
      return;
    }
    onRechercher({ idMedecin: idMedecinSelectionne, jour: jourSelectionne });
  }

  return (
    <div className="card p-3 mb-3">
      <div className="row g-2 align-items-end">
        <div className="col-sm-5">
          <label className="form-label" htmlFor="select-medecin">
            {t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')}
          </label>
          <select id="select-medecin" className="form-select" onChange={onChangementMedecin}>
            <option value="">{t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR')}</option>
            {medecins.map((medecin) => (
              <option key={medecin.id} value={medecin.id}>
                {medecin.titre} {medecin.prenom} {medecin.nom}
              </option>
            ))}
          </select>
        </div>
        <div className="col-sm-4">
          <input id="input-jour" type="date" className="form-control"
            value={jourSelectionne} onChange={(e) => setJourSelectionne(e.target.value)} />
        </div>
        <div className="col-sm-3">
          <button type="button" className="btn btn-primary w-100"
            disabled={idMedecinSelectionne === null} onClick={onClicRechercher}>
            {t('DOCTOR_DAY_PICKER.VIEW_AGENDA')}
          </button>
        </div>
      </div>
    </div>
  );
}

Commentons ce code :

  • ligne 11 : export function DoctorDayPicker({ medecins, onRechercher }: DoctorDayPickerProps) { — un [input.required<Medecin[]>()] [Angular] devient simplement un champ de l’objet props (medecins) ; un output<...>() devient une prop qui contient une fonction de rappel fournie par le parent [(onRechercher)] - [App.tsx] l’appellera exactement comme il souscrivait à l’événement (rechercher) du template [Angular] ;
  • lignes 34-38 : {medecins.map((medecin) => ( <option key={medecin.id} …>…</option> ))} — .map(...), avec [key={medecin.id}], est l’équivalent [React] de [@for (medecin of medecins(); track medecin.id)] côté [Angular] : dans les deux cas, un identifiant stable indique comment reconnaître un médecin déjà affiché s’il est redessiné ;
  • ligne 37 : onChange={onChangementMedecin} — contrairement au champ « login » de [Login.tsx] (qui utilise [onChange] à chaque frappe), un <select> ne notifie qu’au moment où le choix change réellement - le même événement HTML [change] qu’utilisait déjà [(change)="onChangementMedecin($event)]" côté [Angular].

6.10.3. src/app/features/agenda/Agenda.tsx

Portage de [agenda.component.ts/.html] : affiche l’agenda d’un médecin pour un jour donné. Équivalent des exemples 8 et 9 du client AngularJS 1.x original. Depuis l’ajout de l’authentification, ce composant reçoit aussi [peutModifier] : à false (rôle USER), les boutons « Réserver »/« Annuler » disparaissent - l’agenda reste consultable, mais en lecture seule.

import { useTranslation } from 'react-i18next';
import type { AgendaMedecinJour, CreneauAgenda, CreneauJson, RvJson } from '../../core/models/rdv.models';
import './agenda.css';

interface AgendaProps {
  agenda: AgendaMedecinJour | null;
  peutModifier: boolean;
  onReserver: (creneau: CreneauJson) => void;
  onAnnuler: (rv: RvJson) => void;
}

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

export function Agenda({ agenda, peutModifier, onReserver, onAnnuler }: AgendaProps) {
  const { t } = useTranslation();

  function onClicCreneau(creneauAgenda: CreneauAgenda): void {
    if (!peutModifier) {
      return;
    }
    if (creneauAgenda.rv === null) {
      onReserver(creneauAgenda.creneau);
    } else {
      onAnnuler(creneauAgenda.rv);
    }
  }

  if (agenda === null) {
    return <p className="text-muted">{t('AGENDA.EMPTY_STATE')}</p>;
  }

  return (
    <div className="card p-3">
      <h5>
        {t('AGENDA.TITLE_PREFIX')} {agenda.jour} - {agenda.medecin.titre} {agenda.medecin.prenom} {agenda.medecin.nom}
      </h5>
      <table className="table table-hover align-middle">
        <thead>
          <tr><th>{t('AGENDA.COLUMN_SLOT')}</th><th>{t('AGENDA.COLUMN_STATUS')}</th><th></th></tr>
        </thead>
        <tbody>
          {agenda.creneaux.map((creneauAgenda) => (
            <tr key={creneauAgenda.creneau.id}
              className={creneauAgenda.rv === null ? 'creneau-libre' : undefined}>
              <td>
                {formaterHeure(creneauAgenda.creneau.hDebut, creneauAgenda.creneau.mDebut)} -{' '}
                {formaterHeure(creneauAgenda.creneau.hFin, creneauAgenda.creneau.mFin)}
              </td>
              <td>
                {creneauAgenda.rv === null ? (
                  <span className="badge text-bg-success">{t('AGENDA.FREE')}</span>
                ) : (
                  <span className="badge text-bg-secondary">
                    {creneauAgenda.rv.client?.titre} {creneauAgenda.rv.client?.prenom} {creneauAgenda.rv.client?.nom}
                  </span>
                )}
              </td>
              <td>
                {peutModifier && (
                  <button type="button" className={'btn btn-sm ' +
                    (creneauAgenda.rv === null ? 'btn-outline-success' : 'btn-outline-danger')}
                    onClick={() => onClicCreneau(creneauAgenda)}>
                    {creneauAgenda.rv === null ? t('AGENDA.BOOK') : t('AGENDA.CANCEL_APPOINTMENT')}
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Commentons ce code :

  • lignes 30-32 : if (agenda === null) { return <p …>{t('AGENDA.EMPTY_STATE')}</p>; } — un retour anticipé (early return) remplace [@if (agenda(); as monAgenda) { … } @else { … }] côté [Angular] : une fonction composant [React] peut retourner n’importe quel [JSX] à n’importe quel point de son corps, y compris avant la fin - ici, tant qu’aucune recherche n’a été faite (ou que l’agenda est vide), la fonction s’arrête après avoir affiché le message d’état vide, sans même construire le tableau ;
  • ligne 7 : peutModifier: boolean; — nouveauté apportée par l’authentification, comme côté [Angular] : [App.tsx] lui transmet [auth.estAdmin] (cf. plus loin). À false (rôle USER), les boutons « Réserver »/« Annuler » n’existent même pas dans le DOM (cf. ligne 61, [{peutModifier && (…)}]) - pas seulement grisés/désactivés, entièrement absents ;
  • lignes 46-56 : {creneauAgenda.rv === null ? ( <span …>Libre</span> ) : ( <span …>{nom du patient}</span> )} — l’opérateur ternaire remplace [@if (…) { … } @else { … }] côté [Angular] : badge [Bootstrap] vert (« Libre »/« Free ») ou gris (nom du patient), selon l’état du créneau ;
  • ligne 61 : {peutModifier && (…)} — l’opérateur && affiche son second opérande seulement si le premier est vrai (et rien du tout sinon) - la forme la plus courte du rendu conditionnel en [JSX], utilisée ici plutôt que le ternaire parce qu’il n’y a rien à afficher dans le cas contraire ;
  • ligne 65 : {creneauAgenda.rv === null ? t('AGENDA.BOOK') : t('AGENDA.CANCEL_APPOINTMENT')} — comme côté [Angular] (où un pipe peut s’utiliser dans n’importe quelle expression de template), t(...) est une fonction JavaScript ordinaire : elle s’utilise directement dans un opérateur ternaire, sans syntaxe particulière.

Ce composant ne fait aucun appel HTTP lui-même : il affiche des données reçues en props, et notifie son parent (App.tsx) des intentions de l’utilisateur via [onReserver/onAnnuler] - exactement la même séparation des responsabilités que côté [Angular] (reserver/annuler, des output()).

6.10.4. src/app/features/booking-dialog/BookingDialog.tsx

Portage de [booking-dialog.component.ts/.html] : 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 { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { Client, CreneauJson } from '../../core/models/rdv.models';
import './booking-dialog.css';

interface BookingDialogProps {
  ouvert: boolean;
  creneau: CreneauJson | null;
  clients: Client[];
  onConfirmer: (choix: { idClient: number }) => void;
  onFermer: () => void;
}

export function BookingDialog({ ouvert, creneau, clients, onConfirmer, onFermer }: BookingDialogProps) {
  const { t } = useTranslation();
  const [idClientSelectionne, setIdClientSelectionne] = useState<number | null>(null);

  if (!ouvert) {
    return null;
  }

  function onChangementClient(evenement: ChangeEvent<HTMLSelectElement>): void {
    const valeur = evenement.target.value;
    setIdClientSelectionne(valeur === '' ? null : Number(valeur));
  }

  function onClicConfirmer(): void {
    if (idClientSelectionne === null) {
      return;
    }
    onConfirmer({ idClient: idClientSelectionne });
    setIdClientSelectionne(null);
  }

  return (
    <>
      <div className="modal-backdrop fade show"></div>
      <div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal="true">
        <div className="modal-dialog modal-dialog-centered">
          <div className="modal-content">
            <div className="modal-header">
              <h5 className="modal-title">{t('BOOKING_DIALOG.TITLE')}</h5>
              <button type="button" className="btn-close"
                aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} onClick={onFermer}></button>
            </div>
            <div className="modal-body">
              {creneau && (
                <p>
                  {t('BOOKING_DIALOG.SLOT_FROM')} {creneau.hDebut}:{creneau.mDebut.toString().padStart(2, '0')}{' '}
                  {t('BOOKING_DIALOG.SLOT_TO')} {creneau.hFin}:{creneau.mFin.toString().padStart(2, '0')}
                </p>
              )}
              <select id="select-client" className="form-select" onChange={onChangementClient}>
                <option value="">{t('BOOKING_DIALOG.CHOOSE_PATIENT')}</option>
                {clients.map((client) => (
                  <option key={client.id} value={client.id}>
                    {client.titre} {client.prenom} {client.nom}
                  </option>
                ))}
              </select>
            </div>
            <div className="modal-footer">
              <button type="button" className="btn btn-secondary" onClick={onFermer}>
                {t('BOOKING_DIALOG.CANCEL')}
              </button>
              <button type="button" className="btn btn-primary"
                disabled={idClientSelectionne === null} onClick={onClicConfirmer}>
                {t('BOOKING_DIALOG.CONFIRM')}
              </button>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

Commentons ce code :

  • lignes 18-20 : if (!ouvert) { return null; } — un composant [React] peut retourner null pour ne rien afficher du tout : c’est l’équivalent exact de [@if (ouvert()) { … }] côté [Angular] (rien n’est même monté dans le DOM tant que ouvert vaut false), et de [ngIf/ngShow] d’AngularJS 1.x ;
  • ligne 37 : <div className="modal-backdrop fade show"></div> — 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 38 : <div className="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 rendu conditionnel de la ligne 17 ([if (!ouvert) return null;]) qui joue ce rôle ;
  • ligne 44 : aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} — contrairement à [attr.aria-label="'BOOKING_DIALOG.CLOSE_ARIA' | translate"] côté [Angular] (une liaison d’attribut distincte de l’interpolation {{ }}), le [JSX] ne fait aucune différence entre un attribut HTML et le contenu d’un élément : {expression} s’utilise identiquement aux deux endroits.

Comme côté [Angular], ce composant utilise les VRAIES classes [Bootstrap] d’une modale, mais dont la visibilité est pilotée par [React] (un rendu conditionnel sur la prop [ouvert]) plutôt que par le JavaScript de [Bootstrap] ([bootstrap.bundle.js, new bootstrap.Modal(...)]) - pour éviter qu’[Bootstrap] et [React] gèrent chacun, de son côté, la même information (la modale est-elle ouverte ?), une source classique de bugs subtils. 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.

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

Image

C’est ce composant qui joue le rôle tenu par le « contrôleur principal » de l’application AngularJS 1.x originale, et par App côté [Angular] : il détient l’état global de l’application et réagit aux événements des composants de features/ pour appeler [useRdvService()] 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. Un composant [React] réunit dans un seul fichier .tsx ce qu’[Angular] sépare en trois ([app.ts, app.html, app.css]) : le [JSX] retourné par App() EST le template ([App.css] devient importé ci-dessous par son seul effet de bord).

6.11.1. src/app/App.tsx

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from './core/context/auth.context';
import { useSettings } from './core/context/settings.context';
import { useLangue } from './core/context/language.context';
import { useRdvService } from './core/services/rdv.service';
import type { AgendaMedecinJour, Client, CreneauJson, Medecin, RvJson } from './core/models/rdv.models';
import { DoctorDayPicker } from './features/doctor-day-picker/DoctorDayPicker';
import { Agenda } from './features/agenda/Agenda';
import { BookingDialog } from './features/booking-dialog/BookingDialog';
import { Login } from './features/login/Login';
import './App.css';

export function App() {
  const { t } = useTranslation();
  const auth = useAuth();
  const settings = useSettings();
  const langue = useLangue();
  const rdv = useRdvService();

  const [medecins, setMedecins] = useState<Medecin[]>([]);
  const [clients, setClients] = useState<Client[]>([]);
  const [agenda, setAgenda] = useState<AgendaMedecinJour | null>(null);
  const [erreur, setErreur] = useState<string | null>(null);
  const [creneauEnReservation, setCreneauEnReservation] = useState<CreneauJson | null>(null);

  const dernierIdMedecin = useRef<number | null>(null);
  const dernierJour = useRef<string | null>(null);

  useEffect(() => {
    if (auth.estConnecte) {
      chargerListesInitiales();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [auth.estConnecte]);

  function chargerListesInitiales(): void {
    rdv.getAllMedecins().then(setMedecins)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
    rdv.getAllClients().then(setClients)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function onDeconnexion(): void {
    auth.seDeconnecter();
    setMedecins([]); setClients([]); setAgenda(null);
    setErreur(null); setCreneauEnReservation(null);
    dernierIdMedecin.current = null; dernierJour.current = null;
  }

  function onRechercherAgenda(criteres: { idMedecin: number; jour: string }): void {
    setErreur(null);
    dernierIdMedecin.current = criteres.idMedecin;
    dernierJour.current = criteres.jour;
    chargerAgenda();
  }

  function onDemandeReservation(creneau: CreneauJson): void {
    setCreneauEnReservation(creneau);
  }

  function onConfirmerReservation(choix: { idClient: number }): void {
    if (creneauEnReservation === null || dernierJour.current === null) {
      return;
    }
    rdv.ajouterRv(dernierJour.current, choix.idClient, creneauEnReservation.id)
      .then(() => { setCreneauEnReservation(null); chargerAgenda(); })
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function onAnnulerRv(rv: RvJson): void {
    rdv.supprimerRv(rv.id).then(() => chargerAgenda())
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function chargerAgenda(): void {
    if (dernierIdMedecin.current === null || dernierJour.current === null) {
      return;
    }
    rdv.getAgendaMedecinJour(dernierIdMedecin.current, dernierJour.current).then(setAgenda)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  return (
    <div className="container py-4">
      <nav className="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
        <span className="navbar-brand mb-0">{t('APP.TITLE')}</span>
        <div className="d-flex align-items-center gap-2">
          <div className="btn-group btn-group-sm" role="group" aria-label="FR / EN">
            <button type="button" onClick={() => langue.changerLangue('fr')}
              className={'btn ' + (langue.langueCourante === 'fr' ? 'btn-light' : 'btn-outline-light')}>FR</button>
            <button type="button" onClick={() => langue.changerLangue('en')}
              className={'btn ' + (langue.langueCourante === 'en' ? 'btn-light' : 'btn-outline-light')}>EN</button>
          </div>
          {auth.estConnecte && (
            <>
              <span className="badge text-bg-light text-primary">{auth.role}</span>
              <span className="text-white">{auth.nom}</span>
              <button type="button" className="btn btn-sm btn-outline-light" onClick={onDeconnexion}>
                {t('APP.LOGOUT')}
              </button>
            </>
          )}
        </div>
      </nav>

      <div className="row g-2 align-items-center mb-4">
        <div className="col-auto">
          <label className="form-label mb-0" htmlFor="input-url-serveur">{t('APP.SERVER_URL_LABEL')}</label>
        </div>
        <div className="col-sm-4">
          <input id="input-url-serveur" type="text" className="form-control form-control-sm"
            defaultValue={settings.apiBaseUrl} onChange={(e) => settings.setApiBaseUrl(e.target.value)} />
        </div>
      </div>

      {erreur && <div className="alert alert-danger" role="alert">{erreur}</div>}

      {!auth.estConnecte ? (
        <Login />
      ) : (
        <>
          <DoctorDayPicker medecins={medecins} onRechercher={onRechercherAgenda} />
          <Agenda agenda={agenda} peutModifier={auth.estAdmin}
            onReserver={onDemandeReservation} onAnnuler={onAnnulerRv} />
          <BookingDialog ouvert={creneauEnReservation !== null} creneau={creneauEnReservation}
            clients={clients} onConfirmer={onConfirmerReservation} onFermer={() => setCreneauEnReservation(null)} />
        </>
      )}
    </div>
  );
}

Commentons ce code :

  • lignes 27-28 : const dernierIdMedecin = useRef<number | null>(null); const dernierJour = useRef<string | null>(null); — on mémorise la dernière recherche (médecin + jour) pour pouvoir rafraîchir l’agenda après une réservation ou une annulation. Un [useRef](), pas un [useState]() : ces deux valeurs ne servent jamais directement à l’affichage (contrairement à agenda ou erreur), donc pas besoin de redessiner le composant quand elles changent - exactement le raisonnement qui, côté [Angular], gardait [dernierIdMedecin/dernierJour] comme de simples champs privés plutôt que des signaux ; modifier une [useRef]() (.current = …) ne redessine JAMAIS le composant, contrairement à un [setXxx()] de [useState]() ;
  • lignes 30-35 : useEffect(() => { if (auth.estConnecte) { chargerListesInitiales(); } }, [auth.estConnecte]); — réexécute son corps chaque fois que [auth.estConnecte] change - l’équivalent direct de [effect(() => { if (this.auth.estConnecte()) { … } })] côté [Angular]. Il s’exécute aussi une première fois au montage du composant : 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 login ;
  • lignes 89-94 : <div className="btn-group btn-group-sm" …> …FR…EN… </div> — le sélecteur de langue est TOUJOURS visible (y compris sur l’écran de connexion) ; le badge rôle + nom + bouton de déconnexion, eux, ne s’affichent que si [auth.estConnecte] (lignes 97-104, [{auth.estConnecte && (…)})] - exactement la même logique que côté [Angular] [(@if (auth.estConnecte()) { … })] ;
  • lignes 119-130 : {!auth.estConnecte ? ( <Login /> ) : ( <>…</> )} — tant que [useAuth().estConnecte] vaut false, ce composant n’affiche que <Login /> ; toutes les routes du serveur étant désormais protégées par [JwtAuthGuard] (chapitre 3), il serait de toute façon inutile (et source d’erreurs 401) de charger médecins/clients/agenda avant d’être connecté - exactement le même raisonnement que côté [Angular] ;
  • lignes 123-127 : <DoctorDayPicker medecins={medecins} onRechercher={onRechercherAgenda} /> … — les trois composants « métier » de features/ sont assemblés ici, chacun recevant ses props (données + fonctions de rappel) - l’équivalent exact du template [app.html] côté [Angular], où les mêmes trois composants apparaissaient avec [medecins]="medecins()" ([rechercher)="onRechercherAgenda($event)"]….

État ([useState/useRef]), effet ([useEffect]), et gestionnaires d’événements (les fonctions onXxx) : ce sont exactement les quatre ingrédients déjà présentés au chapitre précédent, appliqués ici à l’échelle du composant racine tout entier - rien de nouveau n’a été introduit dans ce fichier, seulement assemblé.

6.12. Utilisation pas à pas de l’application

Les captures suivantes ont été obtenues avec exactement la même séquence d’actions que la variante [Angular] de ce cours, sur ce client [React] : URL du serveur laissée à sa valeur par défaut (http://localhost:8080), comptes de démonstration admin/admin et user/user.

6.12.1. 1. Écran de connexion

Au premier chargement de la page, [useAuth().estConnecte] vaut false (aucune session en localStorage) : seul <Login /> s’affiche.

Image

Écran de connexion

Une tentative avec un couple login/mot de passe invalide affiche le message d’erreur mémorisé par [Login.tsx] :

Image

Erreur de connexion

Le sélecteur FR/EN, toujours visible, traduit également cet écran (et le message d’erreur mémorisé, qui se retraduit sans code supplémentaire, cf. [Login.tsx] plus haut) :

Image

Écran de connexion en anglais

6.12.2. 2. Connexion en tant qu’administrateur (rôle ADMIN, accès complet)

Une fois connecté avec admin/admin, [App.tsx] cesse d’afficher <Login /> et charge médecins/clients ([chargerListesInitiales()], déclenché par le [useEffect(…, [auth.estConnecte])] de la ligne 29) :

Image

Accueil, une fois connecté en ADMIN

Image

Accueil, une fois connecté en ADMIN, en anglais

Après avoir choisi un médecin et un jour dans <DoctorDayPicker />, l’agenda s’affiche avec les boutons « Réserver »/« Annuler » (peutModifier vaut true pour un rôle ADMIN) :

Image

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

Un clic sur « Réserver » ouvre <BookingDialog /> ([ouvert] passe à true, cf. [onDemandeReservation] dans [App.tsx]) :

Image

Fenêtre de réservation (modale Bootstrap)

Après confirmation ([onConfirmerReservation] appelle [rdv.ajouterRv(...)], puis [chargerAgenda()] rafraîchit l’affichage), le créneau apparaît occupé :

Image

Agenda après réservation

Un clic sur « Annuler » ([onAnnulerRv] appelle [rdv.supprimerRv(...))] rend le créneau à nouveau libre :

Image

Agenda après annulation

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

Après déconnexion (onDeconnexion), 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 (cf. [Agenda.tsx, {peutModifier && (…)}]). 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, exactement comme dans la variante [Angular] de ce cours - 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, sur les deux variantes.

Deux différences supplémentaires, propres à ce portage [React], méritent d’être mentionnées ici :

  • les tests unitaires (absents de ce projet comme du projet [Angular] de ce cours) suivraient, en [React], une approche différente : plutôt que TestBed (propre à [Angular]), l’écosystème [React] s’appuie typiquement sur [React Testing Library] - non présentée dans ce document ;
  • la vérification de types des templates ([Angular] strictTemplates, cf. chapitre précédent) n’a pas d’équivalent à configurer séparément côté [React] : c’est le compilateur TypeScript lui-même qui vérifie le [JSX], au même titre que n’importe quel autre fichier .tsx - une simplification de configuration, au prix, en théorie, d’une intégration un peu moins poussée entre le langage de template et le système de types (en pratique, sur ce projet, aucune différence observable).