Skip to content

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

Ce chapitre détaille, fichier par fichier, le contenu du dossier [rdvmedecins-vue-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, et comme dans les variantes [Angular] et [React] de ce cours, le client suit une architecture en couches, qu’on appellera ici V-Services (Vue-Services).

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 [Vue.js], elles sont réunies dans une seule entité, le composant [.vue] (cf. chapitre précédent) - exactement comme en [Angular] et en [React], même si la forme diffère (un fichier à trois blocs plutôt qu’une classe ou une fonction) - 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 composable [useRdvService()] et le composable [useAuth()]). C’est un principe de conception qui vaut aussi bien pour AngularJS 1.x que pour [Angular], [React] ou [Vue.js] - il n’a pas changé.

6.2. Arborescence du projet

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 [Vue.js] plutôt qu’en contrôleurs/vues AngularJS 1.x séparés, en composants standalone [Angular], ou en composants fonctionnels [React]. Par rapport aux deux autres variantes de ce cours :

  • le composant racine tient en un seul fichier (App.vue, plus README.md) plutôt que trois ([Angular] : app.ts/app.html/app.css) ou trois également côté [React] (App.tsx/App.css/README.md) : [<template>] et [<script setup>] fusionnent, dans le même fichier, ce que fusionnait déjà [JSX] côté [React] - et App.vue n’a, comme App.tsx, aucun fichier de configuration d’application équivalent à app.config.ts ;
  • un dossier core/composables/ apparaît, à la place de core/context/ côté [React] (lui-même absent côté [Angular]) : il regroupe les composables qui jouent le rôle des services injectables ([SettingsService], [AuthService], [LanguageService]) de la variante [Angular] - ce mécanisme (composables + [provide]/[inject]) 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.ts, cf. plus loin) - une évolution directe de Bootstrap 3, déjà utilisé par le document original, inchangée par rapport aux variantes [Angular] et [React] de ce cours.

6.3. Fichiers de configuration du projet

6.3.1. package.json

{
  "name": "rdvmedecins-vue-client",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "start": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  },
  "private": true,
  "packageManager": "npm@10.9.7",
  "dependencies": {
    "vue": "^3.5.0",
    "vue-i18n": "^11.0.0",
    "bootstrap": "^5.3.3"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.0",
    "prettier": "^3.8.1",
    "typescript": "~5.8.0",
    "vite": "^6.0.0",
    "vue-tsc": "^2.2.0"
  }
}

Commentons ce code :

  • lignes 5-8 : ["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, sur le port 4200 (cf. vite.config.ts ci-dessous) - le même port que les variantes [Angular] et [React] de ce cours ;
  • ligne 7 : ["build": "vue-tsc -b && vite build",] — différence notable avec [React] ([tsc -b && vite build]) : [vue-tsc] remplace le compilateur TypeScript standard par une variante capable de vérifier aussi les types à l’INTÉRIEUR des [<template>] (par exemple, un [v-model] sur une propriété inexistante, ou un [:medecins] qui ne correspond pas au type attendu par [defineProps]) - un peu comme le fait [Angular] avec son option strictTemplates, alors que [React] se contente de vérifier le [JSX] comme n’importe quel autre TypeScript ;
  • lignes 12-16 : ["dependencies": { … }] — les paquets nécessaires à l’exécution dans le navigateur : [vue] (le cœur du framework), [vue-i18n] (traduction FR/EN, cf. plus loin), [bootstrap] (feuille de style uniquement). À la différence des variantes [Angular] et [React] de ce cours, il n’y a ni équivalent de [@angular/router]/[react-i18next] séparé (vue-i18n embarque directement son moteur, cf. i18n.ts), ni de RxJS ;
  • lignes 17-23 : ["devDependencies": { … }] — les paquets nécessaires uniquement pendant le développement : [@vitejs/plugin-vue] (la transformation des fichiers [.vue] en JavaScript, et le rechargement à chaud), TypeScript, [vue-tsc] (cf. ci-dessus) et [Vite] lui-même. Contrairement à [React] ([@types/react], [@types/react-dom]), [Vue.js] n’a pas besoin de paquets de types séparés : la bibliothèque elle-même est écrite en TypeScript.

Contrairement au serveur [NestJS] (module [commonjs], cf. chapitre 3), ce projet fonctionne en modules ECMAScript natifs (import/export) - exactement comme les variantes [Angular] et [React] de ce cours.

6.3.2. vite.config.ts

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

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

Commentons ce code :

  • ligne 5 : [plugins: [vue()],] — active la transformation des fichiers [.vue] (séparation des trois blocs, compilation du [<template>] en fonction de rendu) et le rechargement à chaud - l’équivalent, pour [Vue.js], de ce que [@vitejs/plugin-react]() faisait pour le [JSX] côté [React] (cf. chapitre précédent) et de ce que [@angular/build:dev-server] fait pour [Angular] en arrière-plan ;
  • lignes 6-8 : [server: { port: 4200, },] — fixe le port du serveur de développement à 4200, le même que [ng serve] côté [Angular] et que le client [React] de ce cours - un choix purement pédagogique, pour que les trois variantes de ce cours restent interchangeables sans changer d’habitude.

Ce fichier n’a pas d’équivalent dans le projet AngularJS 1.x original ; 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",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,

    "strict": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["src"]
}

Commentons ce code :

  • ligne 15 : ["strict": true,] — comme le [tsconfig.json] des variantes [Angular] et [React] 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 18-19 : ["noUnusedLocals": true, "noUnusedParameters": true,] — signale à la compilation toute variable ou tout paramètre déclaré mais jamais utilisé - le même réglage que côté [React] ;
  • ligne 13 : ["noEmit": true] — TypeScript ne sert ici qu’à la VÉRIFICATION de types ([npm run build] lance d’abord [vue-tsc -b], qui échoue si une erreur de type existe, y compris dans un [<template>], cf. package.json ci-dessus) : c’est [Vite] (via esbuild) qui produit réellement le JavaScript exécuté par le navigateur.

Contrairement au [tsconfig.json] de [React] ([jsx: ‘react-jsx’]), ce fichier ne contient aucun réglage propre au framework : c’est [vue-tsc] (et non le compilateur TypeScript standard) qui sait, en amont, comment analyser un fichier [.vue] - un mécanisme séparé de ce fichier de configuration, contrairement à [React] où la prise en charge du [JSX] se règle directement ici.

6.4. Démarrage de l’application

6.4.1. index.html

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>RdvMedecins - client Vue.js</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

Commentons ce code :

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

Il n’y a, comme dans les deux autres variantes, 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.ts

import { createApp } from 'vue';

import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';

import { i18n } from './i18n';
import { creerSettingsStore, SettingsKey } from './app/core/composables/settings.composable';
import { creerAuthStore, AuthKey } from './app/core/composables/auth.composable';
import { creerLanguageStore, LanguageKey } from './app/core/composables/language.composable';
import App from './app/App.vue';

const app = createApp(App);

app.use(i18n);

const settingsStore = creerSettingsStore();
app.provide(SettingsKey, settingsStore);

const authStore = creerAuthStore(settingsStore);
app.provide(AuthKey, authStore);

const languageStore = creerLanguageStore();
app.provide(LanguageKey, languageStore);

const conteneur = document.getElementById('app');
if (!conteneur) {
  throw new Error('Élément #app introuvable dans index.html');
}

app.mount(conteneur);

Commentons ce code :

  • ligne 12 : [const app = createApp(App);] — l’équivalent direct de [bootstrapApplication(App, appConfig)] côté [Angular], et de [createRoot(...).render(...)] côté [React] : [createApp(...)] crée une instance d’application (mais ne la monte pas encore, cf. ligne 30) ;
  • ligne 14 : [app.use(i18n);] — installe le plugin [vue-i18n] : à partir de maintenant, [useI18n()] est utilisable dans le [<script setup>] de n’importe quel composant ;
  • lignes 16-23 : [const settingsStore = creerSettingsStore(); app.provide(SettingsKey, settingsStore); …] — les trois [creerXxxStore()] + [app.provide(...)] de core/composables/ (cf. plus loin) rendent [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 une SUITE d’appels de fonctions plutôt que comme un tableau de fournisseurs ou, côté [React], comme un EMBOÎTEMENT de composants [Provider] autour du composant racine - aucune imbrication visuelle n’est nécessaire ici, contrairement à [React] (comparer avec main.tsx du client React de ce cours : <SettingsProvider><AuthProvider>...) ;
  • lignes 16, 19 : [const settingsStore = … const authStore = creerAuthStore(settingsStore);] — l’ORDRE des appels compte : [creerAuthStore(...)] a besoin que [settingsStore] existe déjà (pour lire [apiBaseUrl]) - il lui est donc transmis directement en paramètre, plutôt que par une injection [Vue.js] : à ce stade, aucun composant n’existe encore, [inject()] ne serait d’aucun secours ;
  • ligne 22 : [const languageStore = creerLanguageStore();] — appelée APRÈS [app.use(i18n)] (ligne 14) : ce composable a besoin que le plugin [vue-i18n] soit déjà installé ;
  • ligne 30 : [app.mount(conteneur);] — monte réellement l’application dans l’élément #app d’index.html - séparé de [createApp(App)] (ligne 11) précisément pour laisser le temps aux lignes intermédiaires (app.use, app.provide) de configurer l’application avant qu’elle ne s’affiche.

Comme les clients [Angular] et [React] de ce cours (une seule page, aucune table de routage), ce client [Vue.js] n’utilise AUCUN routeur : App.vue affiche soit l’écran de connexion, soit le reste de l’application, selon [useAuth().estConnecte] - un simple [v-if] suffit, un routeur ([vue-router] ou autre) n’aurait rien apporté pour une application à une seule page.

6.4.3. src/i18n.ts

import { createI18n } from 'vue-i18n';

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

const dictionnairesCharges = new Set<Langue>();

export const i18n = createI18n({
  legacy: false,
  locale: 'fr',
  fallbackLocale: 'fr',
  messages: {},
});

export async function chargerDictionnaire(langue: Langue): Promise<void> {
  if (dictionnairesCharges.has(langue)) {
    return;
  }
  const reponseHttp = await fetch(`/i18n/${langue}.json`);
  const dictionnaire = (await reponseHttp.json()) as Record<string, unknown>;
  i18n.global.setLocaleMessage(langue, dictionnaire);
  dictionnairesCharges.add(langue);
}

Commentons ce code :

  • ligne 8 : [legacy: false,] — active le mode « Composition API » de [vue-i18n] : [useI18n()] dans les composants (comme [useTranslation()] côté [React]), plutôt que [this.$t] (l’ancien mode, pensé pour l’Options API de [Vue.js] 2) ;
  • lignes 9-11 : [locale: 'fr', fallbackLocale: 'fr', messages: {},] — langue de démarrage, langue de repli si une clé manquait dans le dictionnaire courant, et dictionnaires vides au départ - [language.composable.ts] (cf. plus loin) remplace aussitôt [locale] par le choix mémorisé dans localStorage, s’il y en a un ;
  • lignes 14-22 : [export async function chargerDictionnaire(langue: Langue): Promise<void> { …] — contrairement à [i18next-http-backend] (React) ou à [TranslateHttpLoader] (Angular), [vue-i18n] ne fournit PAS nativement de "loader" HTTP : il attend simplement qu’on lui fournisse les dictionnaires déjà chargés, via [setLocaleMessage(...)]. Cette fonction reconstruit donc, à la main, ce que faisait automatiquement [HttpBackend] côté [React] : elle va chercher, avec [fetch], le fichier [/i18n/<langue>.json] (EXACTEMENT la même URL que les deux autres variantes de ce cours), puis enregistre son contenu auprès de [vue-i18n] ;
  • lignes 15-17 : [if (dictionnairesCharges.has(langue)) { return; }] — évite de re-télécharger une langue déjà vue pendant la session - la même optimisation que la mise en cache interne d’[i18next-http-backend] côté [React].

6.4.4. src/index.css

body {
  background-color: #f5f7fa;
}

.creneau-libre {
  cursor: pointer;
}

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

Commentons ce code :

  • ligne 1 : [body { background-color: #f5f7fa; }] — repris à l’identique des deux autres variantes : 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-11 : [.creneau-libre { cursor: pointer; } .creneau-libre:hover { … }] — appliquées aux lignes du tableau d’agenda représentant un créneau libre (cf. Agenda.vue, [:class="creneauAgenda.rv === null ? ‘creneau-libre’ : undefined"] plus loin) : le curseur prend la forme d’une main sur toute la ligne, et le fond se teinte légèrement en vert au survol - ici, contrairement au client [React] de ce cours (où le curseur "pointer" était porté par le bouton "Réserver" lui-même), c’est bien la ligne entière du tableau qui est cliquable, comme le voulait le document original.

Ce fichier contient les styles globaux, par opposition aux styles « locaux » qu’un composant [Vue.js] peut définir dans son propre bloc [<style scoped>] (non utilisé dans ce projet : [Bootstrap] et index.css suffisent à tout ce que ce document met en œuvre) - le même principe qu’en [Angular] et [React] (fichiers de styles séparés par composant).

6.5. La couche core/models

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

// enveloppe commune à toutes les réponses du serveur (cf. Reponse<T> côté NestJS)
export interface Reponse<T> {
  status: number; // 0 = succès, autre chose = échec (cf. StatutReponse côté serveur)
  data: T | null;
}

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

// [Client] désigne ici un PATIENT du cabinet médical - à ne pas confondre avec "client HTTP" !
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; // format "yyyy-MM-dd"
  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 :

  • les interfaces elles-mêmes sont un copier-coller STRICT des variantes [Angular] et [React] de ce cours (simplement recommentées, comme l’ensemble de ce client) : les interfaces TypeScript décrivant les données échangées avec le serveur ne dépendent d’aucun framework - c’est du TypeScript « pur », qui correspond terme à terme à la classe [Reponse]<T> et aux entités [TypeORM] du serveur [NestJS] (chapitre 3) ;
  • [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é par les trois variantes de ce cours -, 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/composables

Ce dossier joue, pour ce client [Vue.js], le rôle que jouait core/context/ pour le client [React] de ce cours (cf. chapitre précédent, « Partager de la logique ») : il regroupe les trois composables qui tiennent lieu, ici, des services injectables ([SettingsService], [AuthService], une partie de [LanguageService]) côté [Angular].

6.6.1. src/app/core/composables/settings.composable.ts

import { inject, reactive, type InjectionKey } from 'vue';

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

export const SettingsKey: InjectionKey<SettingsStore> = Symbol('SettingsStore');

export function creerSettingsStore(): SettingsStore {
  const store = reactive<SettingsStore>({
    apiBaseUrl: 'http://localhost:8080',
    setApiBaseUrl(url: string) {
      store.apiBaseUrl = url;
    },
  });
  return store;
}

export function useSettings(): SettingsStore {
  const store = inject(SettingsKey);
  if (!store) {
    throw new Error('useSettings() doit être appelé après app.provide(SettingsKey, ...) (cf. src/main.ts)');
  }
  return store;
}

Commentons ce code :

  • ligne 8 : [export const SettingsKey: InjectionKey<SettingsStore> = Symbol('SettingsStore');] — la clé d’injection (cf. chapitre précédent) - un [Symbol] typé, plutôt qu’une simple chaîne de caractères ;
  • lignes 10-18 : [export function creerSettingsStore(): SettingsStore { …] — 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 ; [reactive(...)] (plutôt que [ref()]) est choisi ici parce que l’état comporte PLUSIEURS propriétés (apiBaseUrl et sa fonction de mise à jour) - on lit et écrit directement [store.apiBaseUrl], sans [.value] ;
  • lignes 20-26 : [export function useSettings(): SettingsStore { …] — le composable que les composants appellent ([const settings = useSettings();]), équivalent direct de [inject(SettingsService)] côté [Angular], et de [useSettings()] côté [React].

6.6.2. src/app/core/composables/auth.composable.ts

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 [core/services/rdv.service.ts] (cf. plus loin), qui parle HTTP au serveur.

import { inject, reactive, type InjectionKey } from 'vue';
import type { LoginResultat, Reponse, Role } from '../models/rdv.models';
import type { SettingsStore } from './settings.composable';

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

export interface AuthStore {
  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;
}

export const AuthKey: InjectionKey<AuthStore> = Symbol('AuthStore');

export function creerAuthStore(settings: SettingsStore): AuthStore {
  const sessionInitiale = lireSessionStockee();

  function appliquerSession(session: SessionStockee | null): void {
    store.estConnecte = session !== null;
    store.login = session?.login ?? null;
    store.nom = session?.nom ?? null;
    store.role = session?.role ?? null;
    store.estAdmin = session?.role === 'ADMIN';
    store.accessToken = session?.accessToken ?? null;
    if (session) {
      localStorage.setItem(CLE_STOCKAGE, JSON.stringify(session));
    } else {
      localStorage.removeItem(CLE_STOCKAGE);
    }
  }

  const store = reactive<AuthStore>({
    estConnecte: sessionInitiale !== null,
    login: sessionInitiale?.login ?? null,
    nom: sessionInitiale?.nom ?? null,
    role: sessionInitiale?.role ?? null,
    estAdmin: sessionInitiale?.role === 'ADMIN',
    accessToken: sessionInitiale?.accessToken ?? null,

    async seConnecter(login: string, password: string): Promise<LoginResultat> {
      const reponseHttp = await fetch(`${settings.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;
      appliquerSession(resultat);
      return resultat;
    },

    seDeconnecter(): void {
      appliquerSession(null);
    },
  });

  return store;
}

export function useAuth(): AuthStore {
  const store = inject(AuthKey);
  if (!store) {
    throw new Error('useAuth() doit être appelé après app.provide(AuthKey, ...) (cf. src/main.ts)');
  }
  return store;
}

Commentons ce code :

  • lignes 14-21 : [function lireSessionStockee(): SessionStockee | null { …] — protégée par un try/catch, exactement comme côté [Angular] et [React] : [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 37 : [const sessionInitiale = lireSessionStockee();] — appelée une seule fois, à la construction du store (dans src/main.ts) - exactement le rôle que jouait l’initialiseur [useState(lireSessionStockee)] côté [React], ou [signal(lireSessionStockee())] côté [Angular]. Si l’utilisateur recharge la page après s’être connecté, il reste donc connecté ;
  • lignes 39-51 : [function appliquerSession(session: SessionStockee | null): void { …] — factorise ce que [seConnecter()] et [seDeconnecter()] ont en commun : mettre à jour les propriétés réactives du store ET synchroniser [localStorage] en une seule fois - une petite fonction interne, absente de la version [React] équivalente (qui répétait cette logique dans chacune des deux fonctions, faute d’un point d’écriture commun aussi naturel) ;
  • lignes 53-79 : [const store = reactive<AuthStore>({ … });] — même principe que settings.composable.ts : un seul objet [reactive], dont chaque propriété (estConnecte, login, accessToken…) est modifiée EN PLACE par [appliquerSession()] plutôt que remplacée par un nouvel objet - contrairement à [AuthContext] côté [React], qui doit reconstruire un nouvel objet [value] avec [useMemo]() à chaque changement (cf. chapitre précédent), [Vue.js] n’a ici RIEN de tel à prévoir : seuls les composants qui lisent une propriété ayant réellement changé sont redessinés, automatiquement ;
  • lignes 61-74 : [async seConnecter(login: string, password: string): Promise<LoginResultat> { …] — équivalent de : POST /login, corps JSON { login, password } ; comme côté [React] (fetch/async-await), les étapes s’enchaînent naturellement de haut en bas, sans opérateur RxJS dédié (à la différence d’[AuthService.seConnecter()] côté [Angular], qui renvoie un Observable et enregistre la session dans un opérateur tap() séparé) ;
  • lignes 76-78 : [seDeconnecter(): void { appliquerSession(null); },] — 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).

6.6.3. src/app/core/composables/language.composable.ts

Portage d’une partie de [LanguageService] : centralise le changement de langue (français/anglais) de l’interface, par-dessus [vue-i18n] (cf. i18n.ts plus haut).

import { inject, reactive, watch, type InjectionKey } from 'vue';
import { chargerDictionnaire, i18n, type Langue } from '../../../i18n';

const CLE_STOCKAGE = 'rdvmedecins.langue';

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

export const LanguageKey: InjectionKey<LanguageStore> = Symbol('LanguageStore');

export function creerLanguageStore(): LanguageStore {
  const store = reactive<LanguageStore>({
    langueCourante: i18n.global.locale.value as Langue,
    changerLangue(langue: Langue) {
      localStorage.setItem(CLE_STOCKAGE, langue);
      appliquerLangue(langue);
    },
  });

  function appliquerLangue(langue: Langue): void {
    void chargerDictionnaire(langue).then(() => {
      i18n.global.locale.value = langue;
      document.documentElement.lang = langue;
    });
  }

  watch(i18n.global.locale, (nouvelleLangue) => {
    store.langueCourante = nouvelleLangue as Langue;
  });

  const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null;
  if (langueMemorisee && langueMemorisee !== store.langueCourante) {
    appliquerLangue(langueMemorisee);
  } else {
    void chargerDictionnaire(store.langueCourante);
  }

  return store;
}

export function useLangue(): LanguageStore {
  const store = inject(LanguageKey);
  if (!store) {
    throw new Error('useLangue() doit être appelé après app.provide(LanguageKey, ...) (cf. src/main.ts)');
  }
  return store;
}

Commentons ce code :

  • ligne 15 : [langueCourante: i18n.global.locale.value as Langue,][vue-i18n] expose déjà sa langue courante comme une [ref] réactive ([i18n.global.locale]) : [LanguageStore] ne recrée pas un état séparé, il se contente de la RÉPUBLIER sous un nom stable (langueCourante) - exactement la même idée que côté [Angular], où [LanguageService] republiait simplement [this.translate.currentLang] sous un autre nom ;
  • lignes 22-27 : [function appliquerLangue(langue: Langue): void { …][chargerDictionnaire(langue)] (cf. i18n.ts) va chercher, au besoin, le dictionnaire correspondant, PUIS, une fois résolu, on bascule [i18n.global.locale.value] - contrairement à [i18n.changeLanguage(...)] côté [React] (qui fait les deux choses en une seule fois), ici, [vue-i18n] n’ayant pas de "loader" intégré (cf. i18n.ts), c’est ce composable qui orchestre lui-même les deux étapes ;
  • lignes 29-31 : [watch(i18n.global.locale, (nouvelleLangue) => { store.langueCourante = nouvelleLangue as Langue; });][watch(source, callback)] réexécute callback chaque fois que la valeur observée change - l’équivalent, pour une propriété isolée, de [useEffect](..., [dep]) côté [React], ou de [effect()] côté [Angular]. C’est cette ligne qui réalise concrètement la « republication » annoncée en tête de ce fichier : [store.langueCourante] reste TOUJOURS synchronisé sur [i18n.global.locale], quelle que soit la façon dont cette dernière a changé ;
  • lignes 33-38 : [const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null; …] — au tout premier chargement : 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) - mais il faut malgré tout charger SON dictionnaire, que i18n.ts ne charge pas lui-même - exactement le rôle que jouait le [useEffect](..., []) de language.context.tsx côté [React], exécuté une seule fois au montage.

É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]) et de [vue-i18n] ([Vue.js]).

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

[chargerDictionnaire()] (cf. i18n.ts 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). [vue-i18n] résout ensuite chaque clé ("LOGIN.TITLE", "AGENDA.FREE"…) via le hook [useI18n() (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 / Vue.js",
    "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": { "...": "..." },
  "AGENDA": { "...": "..." },
  "BOOKING_DIALOG": { "...": "..." }
}

Commentons ce code :

  • ce fichier reprend, clé pour clé, celui des variantes [Angular] et [React] de ce cours (mêmes sections DOCTOR_DAY_PICKER/AGENDA/BOOKING_DIALOG, non reproduites ici par souci de place, cf. le fichier livré pour le détail), à deux différences près : la ligne 3 ([TITLE]), qui mentionne désormais [Vue.js] plutôt que [React] ; et quatre nouvelles clés ([DEMO_ACCOUNTS_*], lignes 14-17) affichées par Login.vue (cf. plus loin) pour rappeler les deux comptes de démonstration directement sur l’écran de connexion - un petit ajout de confort, absent des deux autres variantes de ce cours ;
  • un objet JSON imbriqué, une section par composant ; [vue-i18n] 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 [i18next] côté [React] et [TranslateService] côté [Angular].

6.7.2. public/i18n/en.json

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

Commentons ce code :

  • ligne 3 : ["TITLE": "RdvMedecins - NestJS / Vue.js 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 aux variantes [Angular] et [React] 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

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

Ni [Vue.js] ni [React] n’ont 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 composable 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 '../composables/auth.composable';

export function useAuthFetch(): (chemin: string, options?: RequestInit) => Promise<Response> {
  const auth = useAuth();

  return async function authFetch(chemin: string, options: RequestInit = {}): Promise<Response> {
    const entetes = new Headers(options.headers);
    if (auth.accessToken) {
      entetes.set('Authorization', `Bearer ${auth.accessToken}`);
    }

    const reponse = await fetch(chemin, { ...options, headers: entetes });

    if (reponse.status === 401) {
      auth.seDeconnecter();
    }

    return reponse;
  };
}

Commentons ce code :

  • ligne 3 : [export function useAuthFetch(): (chemin: string, options?: RequestInit) => Promise<Response> {] — 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 composable que chaque appelant (ici, [useRdvService()], cf. plus loin) doit explicitement appeler et utiliser à la place de [fetch()] - la même approche que côté [React] ; contrairement à [React] ([useCallback](...)), pas besoin ici de mémoriser la fonction retournée : le corps d’un [<script setup>] ne s’exécute qu’UNE SEULE fois par instance de composant, elle n’est donc construite qu’une seule fois ;
  • lignes 8-10 : [if (auth.accessToken) { entetes.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 [entetes] avant l’appel ;
  • lignes 14-16 : [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 ; [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 18 : [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 composable 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

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

Portage de [RdvService] : le SEUL endroit de l’application (avec [core/composables/auth.composable.ts] 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 { useAuthFetch } from '../interceptors/auth.interceptor';
import { useSettings } from '../composables/settings.composable';
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 settings = useSettings();

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

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

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

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

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

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

Commentons ce code :

  • ligne 5 : [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] et d’[extraire<T>()] côté [React] ;
  • ligne 13 : [export function useRdvService() {] — un composable qui se lit et s’utilise exactement comme [inject(RdvService)] côté [Angular] : on l’appelle une fois en haut du [<script setup>] d’un composant, puis on utilise les fonctions qu’il renvoie ;
  • lignes 17-20 : [async function getAllMedecins(): Promise<Medecin[]> { …] — contrairement à [React] ([useCallback](..., [authFetch, apiBaseUrl])), aucune de ces fonctions n’a besoin d’être mémorisée entre deux rendus : un [<script setup>] [Vue.js] ne s’exécute qu’une seule fois par instance de composant (cf. auth.interceptor.ts plus haut) - ce détail de plomberie, nécessaire côté [React], disparaît entièrement ici ;
  • 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] et [React].

Contrairement à [AuthStore.seConnecter()] (chapitre précédent), aucune des méthodes de ce composable n’a besoin de mémoriser quoi que ce soit après l’appel : c’est le composant appelant (App.vue) qui décide quoi faire du résultat (medecins.value = resultat, agenda.value = resultat…) - la même séparation des responsabilités que côté [Angular] et [React], où [RdvService]/[useRdvService()] ne connaissent rien de l’état affiché à l’écran.

6.10. La couche features : les quatre composants

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

Portage de [login.component.ts/.html] ([Angular]) et de [Login.tsx] ([React]) : l’écran de connexion, équivalent de la vue [login.html] du client AngularJS 1.x original (première vue affichée par l’application). Ce composant ne connaît rien de l’agenda ou des rendez-vous : il se contente de collecter un couple (login, password), de le transmettre à [AuthStore.seConnecter(...)], et d’afficher une éventuelle erreur.

<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuth } from '../../core/composables/auth.composable';

const { t } = useI18n();
const auth = useAuth();

const login = ref('');
const password = ref('');
const enCours = ref(false);
const erreur = ref<string | null>(null);

async function onValider(): Promise<void> {
  if (login.value.trim() === '' || password.value === '') {
    return;
  }
  erreur.value = null;
  enCours.value = true;
  try {
    await auth.seConnecter(login.value, password.value);
    enCours.value = false;
  } catch {
    enCours.value = false;
    erreur.value = 'LOGIN.ERROR';
  }
}
</script>

<template>
  <div class="row justify-content-center">
    <div class="col-sm-8 col-md-6 col-lg-4">
      <form class="card p-4" @submit.prevent="onValider">
        <h5 class="card-title mb-3">{{ t('LOGIN.TITLE') }}</h5>

        <div v-if="erreur" class="alert alert-danger py-2" role="alert">
          {{ t(erreur) }}
        </div>

        <div class="mb-3">
          <label class="form-label" for="input-login">
            {{ t('LOGIN.LOGIN_LABEL') }}
          </label>
          <input id="input-login" type="text" class="form-control"
            autocomplete="username" v-model="login" />
        </div>

        <div class="mb-3">
          <label class="form-label" for="input-password">
            {{ t('LOGIN.PASSWORD_LABEL') }}
          </label>
          <input id="input-password" type="password" class="form-control"
            autocomplete="current-password" v-model="password" />
        </div>

        <button type="submit" class="btn btn-primary w-100" :disabled="enCours">
          {{ enCours ? t('LOGIN.SUBMITTING') : t('LOGIN.SUBMIT') }}
        </button>

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

Commentons ce code :

  • ligne 6 : [const { t } = useI18n();] — un composant standalone [Angular] déclare lui-même, dans son décorateur [@Component], les pipes qu’il utilise ([imports: [TranslatePipe]]) ; un composant [Vue.js], comme un composant [React], n’a rien d’équivalent à déclarer : [useI18n()] est simplement importé et appelé, comme n’importe quelle autre fonction ;
  • lignes 9-12 : [const login = ref(''); const password = ref(''); …] — état purement local au formulaire (ce que l’utilisateur est en train de taper) - l’équivalent des [signal()] locaux du composant [Angular], et de [useState()] côté [React] (ici, une [ref] par valeur plutôt qu’une paire [valeur, setValeur]) ;
  • ligne 33 : [@submit.prevent="onValider"] — 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> et son événement natif [submit] - à la fois la touche Entrée et un clic sur le bouton « Se connecter » (type="submit") déclenchent alors [onValider()] ; le modificateur [.prevent] empêche automatiquement le comportement par défaut du navigateur (recharger la page) - l’équivalent du [evenement?.preventDefault()] explicite du client [React] équivalent, mais exprimé directement dans le [<template>] plutôt que dans le code TypeScript ;
  • lignes 44-45 : [autocomplete="username" v-model="login"][v-model] relie le champ ET à la [ref] [login] dans les deux sens (lecture ET écriture) en un seul attribut - une simplification par rapport à [React] ([value={login} onChange={(e) => setLogin(e.target.value)}], deux attributs séparés) et à [Angular] ([[(ngModel)]] ou [[value]]+(input) selon les cas) ;
  • lignes 23-26 : [catch { enCours.value = false; erreur.value = ‘LOGIN.ERROR’; }] — un couple login/mot de passe invalide fait échouer [seConnecter()] avec une exception JavaScript (levée par auth.composable.ts, 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] et [React]. Mémoriser une CLÉ de traduction plutôt qu’un texte déjà résolu a le même avantage que dans les deux autres variantes : si l’utilisateur bascule de langue pendant que ce message est affiché, il se retraduit tout seul ([t(erreur)], ligne 32, réévalué automatiquement).

Ce composant réunit dans UN SEUL fichier [.vue] ce qu’[Angular] sépare en deux (login.component.ts pour la logique, login.component.html pour le template) : le [<template>] EST le template, le [<script setup>] EST la logique, dans le même fichier - exactement comme le faisait déjà [Login.tsx] côté [React] avec le [JSX] retourné par sa fonction.

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

Portage de [doctor-day-picker.component.ts/.html] ([Angular]) et de [DoctorDayPicker.tsx] ([React]) : 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.

<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Medecin } from '../../core/models/rdv.models';

defineProps<{
  medecins: Medecin[];
}>();

const emit = defineEmits<{
  rechercher: [criteres: { idMedecin: number; jour: string }];
}>();

const { t } = useI18n();

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

function onClicRechercher(): void {
  if (idMedecinSelectionne.value === null) {
    return;
  }
  emit('rechercher', { idMedecin: idMedecinSelectionne.value, jour: jourSelectionne.value });
}
</script>

<template>
  <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">
          {{ t('DOCTOR_DAY_PICKER.DOCTOR_LABEL') }}
        </label>
        <select id="select-medecin" class="form-select" v-model="idMedecinSelectionne">
          <option :value="null">{{ t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR') }}</option>
          <option v-for="medecin in medecins" :key="medecin.id" :value="medecin.id">
            {{ medecin.titre }} {{ medecin.prenom }} {{ medecin.nom }}
          </option>
        </select>
      </div>

      <div class="col-sm-4">
        <label class="form-label" for="input-jour">
          {{ t('DOCTOR_DAY_PICKER.DAY_LABEL') }}
        </label>
        <input id="input-jour" type="date" class="form-control" v-model="jourSelectionne" />
      </div>

      <div class="col-sm-3">
        <button type="button" class="btn btn-primary w-100"
          :disabled="idMedecinSelectionne === null" @click="onClicRechercher">
          {{ t('DOCTOR_DAY_PICKER.VIEW_AGENDA') }}
        </button>
      </div>
    </div>
  </div>
</template>

Commentons ce code :

  • lignes 6-8 : [defineProps<{ medecins: Medecin[]; }>();] — un [input.required<Medecin[]>()] [Angular] devient une PROP [Vue.js] ; contrairement à [React] (un objet props déstructuré, sans déclaration de type séparée), [defineProps<...>()] est une fonction reconnue par le compilateur [Vue.js] à la compilation : elle n’existe même plus dans le JavaScript final ;
  • lignes 10-12 : [const emit = defineEmits<{ rechercher: [criteres: { … }]; }>();] — un [output<...>()] [Angular] devient ici un ÉVÉNEMENT déclaré - [App.vue] l’écoutera avec [@rechercher="onRechercherAgenda"] (cf. plus loin), exactement comme il souscrivait à l’événement (rechercher) du template [Angular] ; côté [React], c’était une simple prop fonction ([onRechercher]) - [Vue.js] distingue ici plus explicitement entrées et sorties, comme [Angular] ;
  • lignes 21-26 : [function onClicRechercher(): void { … emit(‘rechercher’, { … }); }][emit(‘rechercher’, ...)] déclenche l’événement - l’équivalent direct de [rechercher.emit(...)] côté [Angular], et de l’appel de la fonction reçue en prop ([onRechercher(...)]) côté [React] ;
  • ligne 36 : [select id="select-medecin" class="form-select" v-model="idMedecinSelectionne"][v-model] sur un <select> relie directement la valeur choisie à [idMedecinSelectionne.value] - une simplification par rapport à [React] (:value + @change séparés, cf. chapitre précédent) et à [Angular] ([(ngModel)] ou [value]+(change) selon les cas) ;
  • lignes 38-40 : [option v-for="medecin in medecins" :key="medecin.id" :value="medecin.id"][v-for] + [:key] est l’équivalent [Vue.js] de [.map(...)] + [key={...}] côté [React] (et de [@for (medecin of medecins(); track medecin.id)] côté [Angular]) : dans les trois cas, un identifiant stable indique comment reconnaître un médecin déjà affiché s’il est redessiné.

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

Portage de [agenda.component.ts/.html] ([Angular]) et de [Agenda.tsx] ([React]) : 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.

<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { AgendaMedecinJour, CreneauAgenda, CreneauJson, RvJson } from '../../core/models/rdv.models';

defineProps<{
  agenda: AgendaMedecinJour | null;
  peutModifier: boolean;
}>();

const emit = defineEmits<{
  reserver: [creneau: CreneauJson];
  annuler: [rv: RvJson];
}>();

const { t } = useI18n();

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

function onClicCreneau(creneauAgenda: CreneauAgenda, peutModifier: boolean): void {
  if (!peutModifier) {
    return;
  }
  if (creneauAgenda.rv === null) {
    emit('reserver', creneauAgenda.creneau);
  } else {
    emit('annuler', creneauAgenda.rv);
  }
}
</script>

<template>
  <p v-if="agenda === null" class="text-muted">{{ t('AGENDA.EMPTY_STATE') }}</p>

  <div v-else class="card p-3">
    <h5>
      {{ t('AGENDA.TITLE_PREFIX') }} {{ agenda.jour }} - {{ agenda.medecin.titre }}
      {{ agenda.medecin.prenom }} {{ agenda.medecin.nom }}
    </h5>

    <table class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ t('AGENDA.COLUMN_SLOT') }}</th>
          <th>{{ t('AGENDA.COLUMN_STATUS') }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="creneauAgenda in agenda.creneaux" :key="creneauAgenda.creneau.id"
          :class="creneauAgenda.rv === null ? 'creneau-libre' : undefined">
          <td>
            {{ formaterHeure(creneauAgenda.creneau.hDebut, creneauAgenda.creneau.mDebut) }} -
            {{ formaterHeure(creneauAgenda.creneau.hFin, creneauAgenda.creneau.mFin) }}
          </td>
          <td>
            <span v-if="creneauAgenda.rv === null" class="badge text-bg-success">
              {{ t('AGENDA.FREE') }}
            </span>
            <span v-else class="badge text-bg-secondary">
              {{ creneauAgenda.rv.client?.titre }} {{ creneauAgenda.rv.client?.prenom }}
              {{ creneauAgenda.rv.client?.nom }}
            </span>
          </td>
          <td>
            <button v-if="peutModifier" type="button"
              :class="'btn btn-sm ' + (creneauAgenda.rv === null ? 'btn-outline-success' : 'btn-outline-danger')"
              @click="onClicCreneau(creneauAgenda, peutModifier)">
              {{ creneauAgenda.rv === null ? t('AGENDA.BOOK') : t('AGENDA.CANCEL_APPOINTMENT') }}
            </button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

Commentons ce code :

  • lignes 34, 36 : [p v-if="agenda === null" … div v-else …][v-if]/[v-else] remplacent ici, dans le [<template>], le retour anticipé ("early return") utilisé côté [React] ([if (agenda === null) return <p>...</p>;]) : tant qu’aucune recherche n’a été faite (ou que l’agenda est vide), seul le message d’état vide est monté dans le DOM, sans même construire le tableau qui suit ;
  • ligne 7 : [peutModifier: boolean;] — nouveauté apportée par l’authentification, comme côté [Angular] et [React] : App.vue lui transmet [auth.estAdmin] (cf. plus loin). À false (rôle USER), le bouton « Réserver »/« Annuler » n’existe même pas dans le DOM (ligne 67, [v-if="peutModifier"]) - pas seulement grisé/désactivé, entièrement absent ;
  • lignes 58-64 : [span v-if="creneauAgenda.rv === null" … span v-else …][v-if]/[v-else] remplacent ici l’opérateur ternaire utilisé côté [React] - badge [Bootstrap] vert (« Libre »/« Free ») ou gris (nom du patient), selon l’état du créneau ;
  • ligne 67 : [button v-if="peutModifier" type="button"] — contrairement à [React] (l’opérateur [&&], {peutModifier && (…)}), [v-if] tout seul suffit ici en [Vue.js] : il n’y a pas d’équivalent [Vue.js] de [&&] à utiliser pour « n’afficher que si vrai, sinon rien » - [v-if] fait exactement cela par lui-même ;
  • ligne 70 : [{{ 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) et côté [React], [t(...)] est une fonction JavaScript ordinaire : elle s’utilise directement dans une expression [{{ }}], 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.vue) des intentions de l’utilisateur via les événements [reserver]/[annuler] - exactement la même séparation des responsabilités que côté [Angular] et [React].

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

Portage de [booking-dialog.component.ts/.html] ([Angular]) et de [BookingDialog.tsx] ([React]) : 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.

<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Client, CreneauJson } from '../../core/models/rdv.models';

defineProps<{
  ouvert: boolean;
  creneau: CreneauJson | null;
  clients: Client[];
}>();

const emit = defineEmits<{
  confirmer: [choix: { idClient: number }];
  fermer: [];
}>();

const { t } = useI18n();
const idClientSelectionne = ref<number | null>(null);

function onClicConfirmer(): void {
  if (idClientSelectionne.value === null) {
    return;
  }
  emit('confirmer', { idClient: idClientSelectionne.value });
  idClientSelectionne.value = null;
}

function onClicFermer(): void {
  idClientSelectionne.value = null;
  emit('fermer');
}
</script>

<template>
  <template v-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">{{ t('BOOKING_DIALOG.TITLE') }}</h5>
            <button type="button" class="btn-close"
              :aria-label="t('BOOKING_DIALOG.CLOSE_ARIA')" @click="onClicFermer"></button>
          </div>

          <div class="modal-body">
            <p v-if="creneau">
              {{ 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>

            <label class="form-label" for="select-client">
              {{ t('BOOKING_DIALOG.PATIENT_LABEL') }}
            </label>
            <select id="select-client" class="form-select" v-model="idClientSelectionne">
              <option :value="null">{{ t('BOOKING_DIALOG.CHOOSE_PATIENT') }}</option>
              <option v-for="client in clients" :key="client.id" :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">
              {{ t('BOOKING_DIALOG.CANCEL') }}
            </button>
            <button type="button" class="btn btn-primary"
              :disabled="idClientSelectionne === null" @click="onClicConfirmer">
              {{ t('BOOKING_DIALOG.CONFIRM') }}
            </button>
          </div>
        </div>
      </div>
    </div>
  </template>
</template>

Commentons ce code :

  • ligne 35 : [template v-if="ouvert"] — un [<template>] englobant (une balise propre à [Vue.js], qui ne produit ELLE-MÊME aucun élément HTML) porte le [v-if] : rien n’est même monté dans le DOM tant que la modale n’est pas ouverte - l’équivalent exact de [if (!ouvert) return null;] côté [React], et de [@if(ouvert())] côté [Angular] ;
  • ligne 36 : [div class="modal-backdrop fade show"] — le fond semi-transparent qui assombrit le reste de la page - fourni « en dur » ici, comme dans les deux autres variantes, alors que [Bootstrap] l’insère d’habitude lui-même via JavaScript au moment où l’on affiche la modale ;
  • ligne 38 : [div class="modal fade show d-block"][d-block] remplace le rôle normalement tenu par le JavaScript de [Bootstrap] (ajouter [display:block] au moment de l’ouverture) - ici, c’est directement le [v-if] de la ligne 32 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 {{ }}), et un peu comme le [JSX] [React] (qui ne fait aucune différence entre un attribut et le contenu d’un élément), [Vue.js] utilise ici [:aria-label="..."] - le préfixe [:] signalant une LIAISON d’attribut (une expression JavaScript) plutôt qu’une chaîne littérale.

Comme dans les deux autres variantes, ce composant utilise les VRAIES classes [Bootstrap] d’une modale, mais dont la visibilité est pilotée par [Vue.js] (un [v-if] sur la prop [ouvert]) plutôt que par le JavaScript de [Bootstrap] ([bootstrap.bundle.js, new bootstrap.Modal(...)]) - pour éviter qu’elles gèrent chacune, 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

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] et [React] : 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 [Vue.js] réunit dans un seul fichier [.vue] ce qu’[Angular] sépare en trois (app.ts, app.html, app.css) - exactement comme le faisait déjà App.tsx côté [React].

6.11.1. src/app/App.vue

<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuth } from './core/composables/auth.composable';
import { useSettings } from './core/composables/settings.composable';
import { useLangue } from './core/composables/language.composable';
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.vue';
import Agenda from './features/agenda/Agenda.vue';
import BookingDialog from './features/booking-dialog/BookingDialog.vue';
import Login from './features/login/Login.vue';

const { t } = useI18n();
const auth = useAuth();
const settings = useSettings();
const langue = useLangue();
const rdv = useRdvService();

const medecins = ref<Medecin[]>([]);
const clients = ref<Client[]>([]);
const agenda = ref<AgendaMedecinJour | null>(null);
const erreur = ref<string | null>(null);
const creneauEnReservation = ref<CreneauJson | null>(null);
const dernierIdMedecin = ref<number | null>(null);
const dernierJour = ref<string | null>(null);

watch(
  () => auth.estConnecte,
  (estConnecte) => {
    if (estConnecte) {
      chargerListesInitiales();
    }
  },
  { immediate: true },
);

function chargerListesInitiales(): void {
  rdv
    .getAllMedecins()
    .then((resultat) => (medecins.value = resultat))
    .catch((err: unknown) => (erreur.value = String((err as Error).message ?? err)));

  rdv
    .getAllClients()
    .then((resultat) => (clients.value = resultat))
    .catch((err: unknown) => (erreur.value = String((err as Error).message ?? err)));
}

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

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

function onDemandeReservation(creneau: CreneauJson): void {
  creneauEnReservation.value = creneau;
}

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

function onFermerReservation(): void {
  creneauEnReservation.value = null;
}

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

function chargerAgenda(): void {
  if (dernierIdMedecin.value === null || dernierJour.value === null) {
    return;
  }
  rdv
    .getAgendaMedecinJour(dernierIdMedecin.value, dernierJour.value)
    .then((resultat) => (agenda.value = resultat))
    .catch((err: unknown) => (erreur.value = String((err as Error).message ?? err)));
}
</script>

<template>
  <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">{{ t('APP.TITLE') }}</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 ' + (langue.langueCourante === 'fr' ? 'btn-light' : 'btn-outline-light')"
            @click="langue.changerLangue('fr')">
            FR
          </button>
          <button type="button"
            :class="'btn ' + (langue.langueCourante === 'en' ? 'btn-light' : 'btn-outline-light')"
            @click="langue.changerLangue('en')">
            EN
          </button>
        </div>

        <template v-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">
            {{ t('APP.LOGOUT') }}
          </button>
        </template>
      </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">
          {{ t('APP.SERVER_URL_LABEL') }}
        </label>
      </div>
      <div class="col-sm-4">
        <input id="input-url-serveur" type="text" class="form-control form-control-sm"
          v-model="settings.apiBaseUrl" />
      </div>
    </div>

    <div v-if="erreur" class="alert alert-danger" role="alert">
      {{ erreur }}
    </div>

    <Login v-if="!auth.estConnecte" />

    <template v-else>
      <DoctorDayPicker :medecins="medecins" @rechercher="onRechercherAgenda" />

      <Agenda
        :agenda="agenda"
        :peut-modifier="auth.estAdmin"
        @reserver="onDemandeReservation"
        @annuler="onAnnulerRv"
      />

      <BookingDialog
        :ouvert="creneauEnReservation !== null"
        :creneau="creneauEnReservation"
        :clients="clients"
        @confirmer="onConfirmerReservation"
        @fermer="onFermerReservation"
      />
    </template>
  </div>
</template>

Commentons ce code :

  • lignes 25-26 : [const dernierIdMedecin = ref<number | null>(null); const dernierJour = ref<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. Comme côté [React] (un [useRef](), pas un [useState]()), ces deux valeurs ne servent jamais directement à l’affichage (contrairement à agenda ou erreur) - mais, à la différence de [React], [Vue.js] n’a PAS besoin d’un hook spécial ([useRef]) pour une variable qui survit aux rendus sans déclencher de redessin : un [<script setup>] ne s’exécutant qu’une seule fois par instance de composant (contrairement à une fonction composant [React], rejouée à chaque rendu), une simple variable JavaScript (let) aurait tout aussi bien convenu ; [ref()] est utilisé ici par simple uniformité d’écriture avec le reste du fichier ;
  • lignes 28-36 : [watch(() => auth.estConnecte, (estConnecte) => { if (estConnecte) { chargerListesInitiales(); } }, { immediate: true });] — réexécute son corps chaque fois que [auth.estConnecte] change - l’équivalent direct de [effect(() => { if (this.auth.estConnecte()) { … } })] côté [Angular], et du [useEffect](..., [auth.estConnecte]) côté [React]. [{ immediate: true }] le fait s’exécuter aussi une première fois immédiatement, sans attendre un changement - exactement le comportement du [useEffect]() côté [React] (qui s’exécute toujours au moins une fois, au montage) : 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 112-133 : [div class="btn-group btn-group-sm" … FR … EN … template v-if="auth.estConnecte" …] — 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 126-132, [template v-if="auth.estConnecte"]) - exactement la même logique que côté [Angular] et [React] ;
  • ligne 152 : [Login v-if="!auth.estConnecte"] — 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] et [React] ;
  • lignes 154-171 : [template v-else … DoctorDayPicker … Agenda … BookingDialog …] — les trois composants « métier » de features/ sont assemblés ici, chacun recevant ses props (données, préfixées par [:]) et ses gestionnaires d’événements (préfixés par [@]) - l’équivalent exact du template app.html côté [Angular], où les mêmes trois composants apparaissaient avec [medecins]="medecins()" [(rechercher)="onRechercherAgenda($event)"]

État ([ref]), effet ([watch]), et gestionnaires d’événements (les fonctions onXxx) : ce sont exactement les 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 les variantes [Angular] et [React] de ce cours, sur ce client [Vue.js] : 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.vue :

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.vue 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.vue cesse d’afficher <Login /> et charge médecins/clients ([chargerListesInitiales()], déclenché par le [watch(() => auth.estConnecte, …, { immediate: true })] de la ligne 28) :

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

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.vue, [v-if="peutModifier"] sur le bouton). 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 du bouton 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 les variantes [Angular] et [React] 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 trois variantes.

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

  • les tests unitaires (absents de ce projet comme des projets [Angular] et [React] de ce cours) suivraient, en [Vue.js], une approche différente : plutôt que TestBed (propre à [Angular]) ou [React Testing Library] ([React]), l’écosystème [Vue.js] s’appuie typiquement sur [Vue Test Utils] - non présentée dans ce document ;
  • la vérification de types des [<template>] a, ici, un statut particulier : contrairement à [React] (où le compilateur TypeScript standard suffit, le [JSX] étant du JavaScript comme un autre), [Vue.js] fournit [vue-tsc] (cf. package.json, chapitre 5) précisément POUR vérifier les types à l’intérieur des [<template>] - une situation plus proche, sur ce point précis, de [Angular] strictTemplates que de [React], bien qu’il s’agisse d’un outil séparé plutôt que d’une option du compilateur lui-même.