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
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
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
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
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
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
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
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
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
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.
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).
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
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
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 »).
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).
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.
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.
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.
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.
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | |
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.

Écran de connexion
Une tentative avec un couple login/mot de passe invalide affiche le message d’erreur mémorisé par Login.vue :

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

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

Accueil, une fois connecté en ADMIN

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

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

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

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

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 :

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

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.