Skip to content

6. Chapter 5 - The [Vue.js] client for the [RdvMedecins] application

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

6.1. Architecture Overview

As in the original document, and as in the [Angular] and [React] variants of this course, the client follows a layered architecture, which we will refer to here as V-Services (View-Services).

One difference in terminology is worth noting: in AngularJS and 1.x, the View (the HTML template) and the Controller (the associated JavaScript class) were two distinct entities, linked by the $scope. In [Vue.js], they are combined into a single entity, the [.vue] component (see previous chapter) —exactly as in [Angular] and [React], even though the form differs (a file with three blocks rather than a class or a function)—which is why, in what follows, we will simply refer to “components” where the original document distinguished between view and controller.

As in the original document, the components do not communicate directly with JAMAIS or HTTP: this role is reserved exclusively for the Services layer (in this case, the composable [useRdvService()] and the composable [useAuth()]). This is a design principle that applies equally to AngularJS, 1.x, and [Angular], [React], or [Vue.js]—it has not changed.

6.2. Project Structure

This structure directly follows the flow of the original document (login, select doctor/date, view calendar, make appointment), simply organized here into components [Vue.js] rather than separate controllers/views AngularJS 1.x, standalone components ([Angular]), or functional components ([React]). Compared to the other two variations in this course:

  • the root component fits into a single file (App.vue, plus README.md) rather than three ([Angular]: app.ts/app.html/app.css) or three on the [React] side as well (App.tsx/App.css/README.md): [<template>] and [<script setup>] merge, within the same file, what [JSX] had already merged on the [React] side - and App.vue, like App.tsx, has no application configuration file equivalent to app.config.ts;
  • A `core/composables/` folder appears on the [React] side in place of `core/context/` (which is itself absent on the [Angular] side): it contains the composables that act as injectable services ([SettingsService], [AuthService], [LanguageService]) for the [Angular] variant—this mechanism (composables + [provide]/[inject]) was introduced in the previous chapter, along with a generic example (see the previous chapter, “Sharing Logic”).

[Bootstrap] 5 is used in all views (imported into main.ts; see below)—a direct evolution of Bootstrap 3, already used by the original document, unchanged from the [Angular] and [React] variants in this course.

6.3. Project configuration files

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

Let’s comment on this code:

  • Lines 5–8: ["scripts": { … }] — the available [npm run <nom>] commands. [start] (as dev) starts the [Vite] development server with automatic reloading whenever a source file is modified, on port 4200 (see vite.config.ts below) - the same port as the [Angular] and [React] variants in this course;
  • Line 7: ["build": "vue-tsc -b && vite build",] — notable difference from [React] ([tsc -b && vite build]): [vue-tsc] replaces the standard TypeScript compiler with a variant capable of also checking the types of INTÉRIEUR and [<template>] (for example, a [v-model] on a nonexistent property, or a [:medecins] that does not match the type expected by [defineProps]) - much like [Angular] does with its strictTemplates option, whereas [React] simply checks the [JSX] just like any other TypeScript;
  • Lines 12–16: ["dependencies": { … }] — the packages required for execution in the browser: [vue] (the core of the framework), [vue-i18n] (translation of FR/EN; see below), [bootstrap] (style sheet only). Unlike the [Angular] and [React] variants in this course, there is neither a separate equivalent of [@angular/router]/[react-i18next] (vue-i18n embeds its engine directly; see i18n.ts) nor of RxJS;
  • lines 17–23: ["devDependencies": { … }] — packages required only during development: [@vitejs/plugin-vue] (conversion of [.vue] files to JavaScript, and hot reloading), TypeScript, [vue-tsc] (see above), and [Vite] itself. Unlike [React] ([@types/react], [@types/react-dom]), [Vue.js] does not require separate type packages: the library itself is written in TypeScript.

Unlike the [NestJS] server ([commonjs] module, see Chapter 3), this project runs as native ECMAScript modules (import/export)—exactly like the [Angular] and [React] variants in this course.

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

Let’s comment on this code:

  • line 5: [plugins: [vue()],] — enables the transformation of [.vue] files (separation of the three blocks, compilation of [<template>] based on the rendering mode) and hot reloading - the equivalent, for [Vue.js], of what [@vitejs/plugin-react]() did for [JSX] on the [React] side (see previous chapter) and what [@angular/build:dev-server] does for [Angular] in the background;
  • lines 6–8: [server: { port: 4200, },] — sets the development server port to 4200, the same as [ng serve] on the [Angular] side and as the [React] client in this course—a purely educational choice, so that the three variants of this course remain interchangeable without changing your workflow.

This file has no equivalent in the original AngularJS 1.x project; on the [Angular] side of this course, [angular.json] serves this purpose; on the [NestJS] side (Chapter 3), it is [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"]
}

Let’s break down this code:

  • line 15: ["strict": true,] — like [tsconfig.json] in the variants [Angular] and [React] from this course, the full strict mode of TypeScript is active: every variable must have a determinable type, and every null or undefined value must be handled explicitly;
  • lines 18–19: ["noUnusedLocals": true, "noUnusedParameters": true,] — flags any variable or parameter declared but never used during compilation—the same setting as for [React];
  • Line 13: ["noEmit": true] — TypeScript is used here only for the VÉRIFICATION types ([npm run build] first launches [vue-tsc -b], which fails if a type error occurs, including in a [<template>]; see package.json above): it is [Vite] (via esbuild) that actually produces the JavaScript executed by the browser.

Unlike the [tsconfig.json] file generated from [React] ([jsx: ‘react-jsx’]), this file contains no framework-specific settings: it is [vue-tsc] (and not the standard TypeScript compiler) that determines, in advance, how to parse a [.vue] file—a mechanism separate from this configuration file, unlike [React], where support for [JSX] is configured directly here.

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

Let’s comment on this code:

  • line 2: [<html lang="fr">] — just like [Angular] and [React], since this page, HTML, is static (loaded by the browser AVANT when the application [Vue.js] starts), it cannot immediately reflect a language selection made later by the user;
  • line 10: [<div id="app"></div>] — the only element this page contains initially, and which [Vue.js] fills entirely once mounted (see main.ts below) - the exact equivalent of <app-root></app-root> on the [Angular] side, of <div id="root"></div> on the [React] side, and of [ng-app="rdvmedecinsApp"] on the <html> tag of the original document (AngularJS 1.x, 2014); only the identifier changes (convention [Vue.js]: "app");
  • line 11: [<script type="module" src="/src/main.ts"></script>] — like [React], it is index.html that directly references main.ts (a convention specific to [Vite]), rather than the other way around. In development, [Vite] transforms and serves this file on the fly; in production ([npm run build]), it is, of course, compiled beforehand.

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

6.4.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 not found in index.html');
}

app.mount(conteneur);

Let’s comment on this code:

  • line 12: [const app = createApp(App);] — the direct equivalent of [bootstrapApplication(App, appConfig)] on the [Angular] side, and of [createRoot(...).render(...)] on the [React] side: [createApp(...)] creates an application instance (but does not mount it yet; see line 30);
  • line 14: [app.use(i18n);] — installs the [vue-i18n] plugin: from now on, [useI18n()] can be used in the [<script setup>] of any component;
  • lines 16–23: [const settingsStore = creerSettingsStore(); app.provide(SettingsKey, settingsStore); …] — the three [creerXxxStore()] + [app.provide(...)] from core/composables/ (see below) make [useSettings()], [useAuth()], and [useLangue()] usable from any descendant component - the equivalent of the three [provideXxx()] from [app.config.ts] on the [Angular] ([providers: [...]]) side, but expressed here as a SUITE of function calls rather than as an array of providers or, on the [React] side, as a EMBOÎTEMENT of [Provider] components surrounding the root component - no visual nesting is necessary here, unlike in [React] (compare with main.tsx from the React client in this course: <SettingsProvider><AuthProvider>...) ;
  • lines 16, 19: [const settingsStore = … const authStore = creerAuthStore(settingsStore);] — the ORDRE of the calls matters: [creerAuthStore(...)] requires that [settingsStore] already exist (to read [apiBaseUrl])—so it is passed directly as a parameter, rather than via a [Vue.js] injection: at this stage, no components exist yet, so [inject()] would be of no help;
  • line 22: [const languageStore = creerLanguageStore();] — calls APRÈS and [app.use(i18n)] (line 14): this composable requires that the [vue-i18n] plugin be already installed;
  • line 30: [app.mount(conteneur);] — actually mounts the application in the #app element of index.html—separated from [createApp(App)] (line 11) precisely to allow time for the intermediate lines (app.use, app.provide) to configure the application before it is displayed.

Like the clients [Angular] and [React] in this course (single page, no routing table), this client, [Vue.js], does not use the AUCUN router: App.vue displays either the login screen or the rest of the application, depending on [useAuth().estConnecte]—a simple [v-if] is sufficient; a router ([vue-router] or another) would not have added any value for a single-page application.

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

Let’s comment on this code:

  • line 8: [legacy: false,] — enables the “API Composition” mode of [vue-i18n]: [useI18n()] in the components (such as [useTranslation()] on the [React] side), rather than [this.$t] (the old mode, intended for the API option of [Vue.js] 2);
  • lines 9–11: [locale: 'fr', fallbackLocale: 'fr', messages: {},] — startup language, fallback language if a key is missing from the current dictionary, and empty dictionaries at startup - [language.composable.ts] (see below) immediately replaces [locale] with the selection stored in localStorage, if there is one;
  • lines 14–22: [export async function chargerDictionnaire(langue: Langue): Promise<void> { …] — unlike [i18next-http-backend] (React) or [TranslateHttpLoader] (Angular), [vue-i18n] does not natively provide a “loader” like PAS: it simply waits to be supplied with the dictionaries that have already been loaded, via [setLocaleMessage(...)]. This function therefore manually reconstructs what [HttpBackend] did automatically on the [React] side: it uses [fetch] to retrieve the file [/i18n/<langue>.json] (EXACTEMENT is the same URL as the other two variants of this course), then saves its contents to [vue-i18n];
  • lines 15–17: [if (dictionnairesCharges.has(langue)) { return; }] — prevents re-downloading a language that has already been encountered during the session — the same optimization as the internal caching of [i18next-http-backend] on the [React] side.

6.4.4. src/index.css

body {
  background-color: #f5f7fa;
}

.creneau-libre {
  cursor: pointer;
}

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

Let’s comment on this code:

  • line 1: [body { background-color: #f5f7fa; }] — identical to the other two variants: a very light gray rather than the default pure white, so that the [Bootstrap] cards (white background, .card) stand out slightly from the page background;
  • lines 5–11: [.creneau-libre { cursor: pointer; } .creneau-libre:hover { … }] — applied to the rows in the calendar table representing an available time slot (see Agenda.vue, [:class="creneauAgenda.rv === null ? ‘creneau-libre’ : undefined"] below): the cursor takes the form of a hand across the entire row, and the background turns slightly green on hover—here, unlike the [React] client from this course (where the “pointer” cursor was applied to the “Reserve” button itself), the entire row of the table is clickable, as intended in the original document.

This file contains global styles, as opposed to the “local” styles that a [Vue.js] component can define within its own [<style scoped>] block (not used in this project: [Bootstrap] and index.css are sufficient for everything this document implements)—the same principle as in [Angular] and [React] (separate style files per component).

6.5. The core/models layer

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

// envelope common to all server responses (see Reponse<T> on the NestJS side)
export interface Reponse<T> {
  status: number; // 0 = success, anything else = failure (see StatutReponse on the server side)
  data: T | null;
}

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

// [Client] refers here to a PATIENT from the doctor’s office—not to be confused with “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; // "yyyy-MM-dd" format
  client: Client | null;
  creneau: CreneauJson | null;
}

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

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

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

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

Let’s comment on this code:

  • The interfaces themselves are a copy-and-paste of STRICT from the variants [Angular] and [React] in this course (simply rewritten, like the entire client): The TypeScript interfaces describing the data exchanged with the server do not depend on any framework—it is “pure” TypeScript “pure” TypeScript, which corresponds exactly to the [Reponse]<T> class and the [TypeORM] entities of the [NestJS] server (Chapter 3);
  • [Role]/[LoginResultat]—exact copies of the server types of the same name (src/entities/user.entity.ts, auth/login-resultat.model.ts, Chapter 3).

In AngularJS and 1.x (2014), the pure JavaScript did not allow the expected data format to be described in this way. With TypeScript—used by all three variants of this course—a typo in a field name is flagged even before the program is run, right at compile time.

6.6. The core/composables layer

For this client, [Vue.js], this directory plays the same role that core/context/ played for the client [React] in this course (see the previous chapter, “Sharing Logic”): it groups together the three composables that serve, in this case, as injectable services ([SettingsService], [AuthService], and part of [LanguageService]) on the [Angular] side.

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

Let’s comment on this code:

  • line 8: [export const SettingsKey: InjectionKey<SettingsStore> = Symbol('SettingsStore');] — the injection key (see previous chapter) — a typed [Symbol], rather than a simple string;
  • lines 10–18: [export function creerSettingsStore(): SettingsStore { …] — exactly replaces [readonly apiBaseUrl = signal(‘http://localhost:8080’);] with [SettingsService] on the [Angular] side: the same default value, with the same option to modify it via the “URL from the server” field on the home view of the original document; [reactive(...)] (rather than [ref()]) is chosen here because the report includes PLUSIEURS properties (apiBaseUrl and its update function)—[store.apiBaseUrl] is read and written directly, without [.value];
  • Lines 20–26: [export function useSettings(): SettingsStore { …] — the composable that the components call ([const settings = useSettings();]), the direct equivalent of [inject(SettingsService)] on the [Angular] side, and of [useSettings()] on the [React] side.

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

Port of [AuthService]: handles the user’s login, calls [POST /login], stores the received token JWT (and the accompanying identity/role), and ensures it survives a page reload using [localStorage]. This is the only place in the application, along with [core/services/rdv.service.ts] (see below), that communicates with the server using HTTP.

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

Let’s break down this code:

  • lines 14–21: [function lireSessionStockee(): SessionStockee | null { …] — protected by a try/catch block, just like [Angular] and [React]: [localStorage] may be unavailable (very restrictive private browsing) or contain a corrupted value—in this case, we simply assume there is no session;
  • line 37: [const sessionInitiale = lireSessionStockee();] — called only once, when the store is constructed (in src/main.ts) - exactly the same role played by the initializer [useState(lireSessionStockee)] on the [React] side, or [signal(lireSessionStockee())] on the [Angular] side. If the user reloads the page after logging in, they remain logged in;
  • lines 39–51: [function appliquerSession(session: SessionStockee | null): void { …] — extracts what [seConnecter()] and [seDeconnecter()] have in common: updating the reactive properties of the store ET and synchronizing [localStorage] in a single step—a small internal function, absent from the equivalent version [React] (which repeated this logic in each of the two functions, due to the lack of a similarly natural common write point);
  • lines 53–79: [const store = reactive<AuthStore>({ … });] — same principle as settings.composable.ts: a single [reactive] object, for which each property (estConnecte, login, accessToken…) is modified—EN, PLACE, and so on—by [appliquerSession()] rather than replaced by a new object —unlike [AuthContext] on the [React] side, which must reconstruct a new object [value] using [useMemo]() with every change (see previous chapter), [Vue.js] does not need to do this here: only the components that read a property that has actually changed are automatically redrawn;
  • lines 61–74: [async seConnecter(login: string, password: string): Promise<LoginResultat> { …] — equivalent to: POST /login, body JSON { login, password }; as on the [React] side (fetch/async-await), the steps flow naturally from top to bottom, without a dedicated RxJS operator (unlike [AuthService.seConnecter()] on the [Angular] side, which returns an Observable and saves the session in a separate tap() operator);
  • lines 76–78: [seDeconnecter(): void { appliquerSession(null); },] — purely local (no server call): A JWT token is not “revoked” on the server side in this port; it expires on its own after JWT_EXPIRES_IN (see the .env server, Chapter 3).

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

Port of a portion of [LanguageService]: centralizes the interface language switch (French/English) on top of [vue-i18n] (see i18n.ts above).

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

Let’s comment on this code:

  • Line 15: [langueCourante: i18n.global.locale.value as Langue,] [vue-i18n] already exposes its current language as a reactive [ref] ([i18n.global.locale]): [LanguageStore] does not create a separate state; it simply uses the RÉPUBLIER under a stable name (langueCourante)—exactly the same concept as with [Angular], where [LanguageService] simply republished [this.translate.currentLang] under a different name;
  • lines 22–27: [function appliquerLangue(langue: Langue): void { …] [chargerDictionnaire(langue)] (see i18n.ts) retrieves, if necessary, the corresponding dictionary, PUIS; once resolved, we switch to [i18n.global.locale.value]—unlike [i18n.changeLanguage(...)] on the [React] side (which does both things at once); here, since [vue-i18n] does not have a built-in “loader” (see i18n.ts), this composable orchestrates both steps itself;
  • lines 29–31: [watch(i18n.global.locale, (nouvelleLangue) => { store.langueCourante = nouvelleLangue as Langue; });] [watch(source, callback)] re-executes the callback every time the observed value changes—the equivalent, for a single property, of [useEffect](..., [dep]) on the [React] side, or [effect()] on the [Angular] side. This line is what actually carries out the “republication” mentioned at the beginning of this file: [store.langueCourante] remains TOUJOURS, synchronized with [i18n.global.locale], regardless of how the latter has changed;
  • lines 33–38: [const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null; …] — on the very first load: if the user had already selected a language during a previous visit, it is restored; otherwise, the default language set in i18n.ts (French)—but the SON dictionary must still be loaded, which i18n.ts does not load itself—exactly the role played by [useEffect](..., []) of language.context.tsx on the [React] side, executed only once during setup.

Equivalent to the “FR / EN” button in the original AngularJS 1.x client, which at the time relied on the [angular-translate] library —the predecessor, within the AngularJS 1.x ecosystem, of [ngx-translate] ([Angular]), followed by [i18next] ([React]) and [vue-i18n] ([Vue.js]).

6.7. Translation dictionaries (public/i18n/)

[chargerDictionnaire()] (see i18n.ts above) loads one of these two files via a [HTTP GET /i18n/<langue>.json] request whenever the language changes (or is used for the first time). [vue-i18n] then resolves each key ("LOGIN.TITLE", "AGENDA.FREE"…) via the [useI18n() (fonction t(...))] hook used in all components in the features/ directory (see later in this chapter).

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": { "...": "..." }
}

Let’s comment on this code:

  • This file mirrors, key by key, the files for variants [Angular] and [React] from this course (the same sections as in DOCTOR_DAY_PICKER/AGENDA/BOOKING_DIALOG, not reproduced here for space reasons; see the provided file for details), with two exceptions: line 3 ([TITLE]), which now refers to [Vue.js] instead of [React]; and four new keys ([DEMO_ACCOUNTS_*], lines 14–17) displayed by Login.vue (see below) to show the two demo accounts directly on the login screen—a small convenience feature not included in the other two versions of this course;
  • A nested JSON object, one section per component; [vue-i18n] flattens it into a flat dictionary, where each complete key ("LOGIN.TITLE") is formed by concatenating the path with dots—exactly as [i18next] is appended to [React], and [TranslateService] is appended to [Angular].

6.7.2. public/i18n/en.json

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

Let’s break down this code:

  • line 3: ["TITLE": "RdvMedecins - NestJS / Vue.js port",] — only the subtitle changes: “[RdvMedecins]” remains the same in both languages; this is the application’s proper name.

Both files must remain structurally identical (same keys in both): this ensures that a key exists in both languages. The parts of PAS that are not translated remain unchanged compared to the [Angular] and [React] variants in this course: error messages returned by the server [NestJS] remain in French regardless of the language selected on the client side, and the names of doctors and patients come directly from the database.

6.8. The core/interceptors layer

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

Neither [Vue.js] nor [React] has a built-in HTTP interceptor like [HttpClient]/[withInterceptors([...])] on the [Angular] side. This file reimplements the same concept as a composable that encapsulates [fetch]: this is the modern equivalent of the $http interceptors from AngularJS and 1.x already encountered in the original document (chapter “Example 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;
  };
}

Let’s break down this code:

  • line 3: [export function useAuthFetch(): (chemin: string, options?: RequestInit) => Promise<Response> {] — unlike [authInterceptor] on the [Angular] side (a function registered once and for all in [app.config.ts], which is then automatically applied to each call to [HttpClient]), [useAuthFetch()] is a composable that each caller (here, [useRdvService()], see below) must explicitly call and use in place of [fetch()]—the same approach as on the [React] side; unlike [React] ([useCallback](...)), there is no need here to store the returned function: the body of a [<script setup>] is executed only UNE SEULE times per component instance, so it is constructed only once;
  • lines 8–10: [if (auth.accessToken) { entetes.set(‘Authorization’, …); }] — the direct equivalent of [request.clone({ setHeaders: { Authorization: … } })] on the [Angular] side. Unlike a [HttpClient] query (which is immutable), the options for [fetch] are simply a JavaScript object: there is no need to create a “cloned” copy of it; simply construct the [entetes] object before the call;
  • lines 14–16: [if (reponse.status === 401) { auth.seDeconnecter(); }] — same principle as for [Angular] (catchError + HttpErrorResponse.status === 401): A 401 during a session means the token is no longer valid; the client is gracefully disconnected; [fetch] does not reject its Promise for a HTTP status as an error (unlike an Observable [HttpClient])—which is why this test targets [reponse.status] directly, without a try/catch block;
  • line 18: [return reponse;] — the response (potentially a 401) is nevertheless returned as-is to the caller, who remains responsible for handling the consequences (see useRdvService(), extract<T>(), below) - The interceptor must not hide the error from the calling code.

One could manually add [Authorization: Bearer ...] to each of the methods in [useRdvService()]. A shared composable avoids this repetition, offering the same benefit as a [Angular] interceptor: the question “How do we authenticate a request?” is answered in a single location.

6.9. The core/services layer

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

Porting [RdvService]: the SEUL part of the application (along with [core/composables/auth.composable.ts] for the connection itself) that communicates with the server via HTTP and [NestJS]. This is the direct equivalent of the [dao] service presented in the chapter “Example 6: HTTP Services” in the original document (which used the AngularJS and $http services).

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

Let’s comment on this code:

  • line 5: [async function extraire<T>(reponseHttp: Response): Promise<T> {] — rather than repeating the “if status !== 0, it’s an error” in each public function, we centralize it here just once—which is exactly the role of [RdvService.extraire()] relative to [Angular] and of [extraire<T>()] relative to [React];
  • Line 13: [export function useRdvService() {]—a composable that reads and is used exactly like [inject(RdvService)] on the [Angular] side: it is called once at the top of a component’s [<script setup>], and then the functions it returns are used;
  • lines 17–20: [async function getAllMedecins(): Promise<Medecin[]> { …] — unlike [React] ([useCallback](..., [authFetch, apiBaseUrl])), none of these functions need to be cached between renderings: a [<script setup>] [Vue.js] is executed only once per component instance (see auth.interceptor.ts above) - this technical detail, which is necessary on the [React] side, disappears entirely here;
  • each function corresponds exactly to one of the eleven routes of the [NestJS] controller (Chapter 3)—in fact, the same names appear on both sides (getAllMedecins, ajouterRv, supprimerRv…), which makes it easier to compare the server and client sides, just as with [Angular] and [React].

Unlike [AuthStore.seConnecter()] (previous chapter), none of the methods in this composable need to retain any data after the call: it is the calling component (App.vue) that decides what to do with the result (medecins.value = result, agenda.value = result…) - the same separation of responsibilities as seen with [Angular] and [React], where [RdvService] and [useRdvService()] know nothing about the state displayed on the screen.

6.10. The features layer: the four components

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

Porting of [login.component.ts/.html] ([Angular]) and [Login.tsx] ([React]): the login screen, equivalent to view [login.html] of the original client AngularJS 1.x (the first view displayed by the application). This component has no knowledge of the calendar or appointments: it simply collects a pair (username, password), forwards it to [AuthStore.seConnecter(...)], and displays any errors.

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

Let’s break down this code:

  • line 6: [const { t } = useI18n();] — a standalone component [Angular] declares, within its decorator [@Component], the pipes it uses ([imports: [TranslatePipe]]); a component [Vue.js], like a component [React], has nothing equivalent to declare: [useI18n()] is simply imported and called, just like any other function;
  • lines 9–12: [const login = ref(''); const password = ref(''); …] — a state that is purely local to the form (what the user is currently typing) — the equivalent of the local [signal()] values of the [Angular] component, and the [useState()] on the [React] side (here, a [ref] by value rather than a [valeur, setValeur] pair);
  • line 33: [@submit.prevent="onValider"] — a deliberate difference from the [Angular] variant in this course: rather than listening (keyup.enter) to each field separately, this component uses a genuine <form> element and its native event [submit]—both the Enter key and a click on the “Log In” button (type="submit") then trigger [onValider()]; the [.prevent] modifier automatically prevents the browser’s default behavior (reloading the page)—the equivalent of the explicit client-side [evenement?.preventDefault()], but expressed directly in the [<template>] rather than in the TypeScript code;
  • lines 44–45: [autocomplete="username" v-model="login"][v-model] links the field ET to [ref] and [login] in both directions (read: ET; write) into a single attribute—a simplification compared to [React] ([value={login} onChange={(e) => setLogin(e.target.value)}], two separate attributes) and [Angular] ([[(ngModel)]] or [[value]]+(input), as applicable);
  • lines 23–26: [catch { enCours.value = false; erreur.value = ‘LOGIN.ERROR’; }] — an invalid username/password pair causes [seConnecter()] to fail with a JavaScript exception (raised by auth.composable.ts, see above): “LOGIN.ERROR” is displayed to the user, without attempting to distinguish between a nonexistent username and an incorrect password—exactly the same approach as in the cases of [Angular] and [React]. Storing a translation CLÉ rather than an already-resolved text has the same advantage as in the other two variants: if the user switches languages while this message is displayed, it translates itself automatically ([t(erreur)], line 32, automatically re-evaluated).

This component combines in UN the files SEUL and [.vue], which [Angular] separates into two (login.component.ts for the logic, login.component.html for the template): [<template>] and EST for the template, [<script setup>] and EST for the logic, in the same file—exactly as [Login.tsx] already did on the [React] side with the [JSX] returned by its function.

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

Port of [doctor-day-picker.component.ts/.html] ([Angular]) and [DoctorDayPicker.tsx] ([React]): the component that allows you to select a doctor and a date, then request to view their schedule. Conceptually equivalent to examples 7 through 10 from the original AngularJS and 1.x client.

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

Let’s break down this code:

  • lines 6–8: [defineProps<{ medecins: Medecin[]; }>();] — a [input.required<Medecin[]>()] [Angular] becomes a PROP [Vue.js]; unlike [React] (a destructured `props` object, without a separate type declaration), [defineProps<...>()] is a function recognized by the compiler as [Vue.js] at compile time: it no longer even exists in the final JavaScript;
  • Lines 10–12: [const emit = defineEmits<{ rechercher: [criteres: { … }]; }>();] — a [output<...>()] [Angular] becomes a declared ÉVÉNEMENT here - [App.vue] will listen to it with [@rechercher="onRechercherAgenda"] (see below), exactly as it subscribed to the (search) event of the [Angular] template; As for [React], it was a simple prop function ([onRechercher]) - [Vue.js] distinguishes more explicitly between inputs and outputs here, like [Angular];
  • lines 21–26: [function onClicRechercher(): void { … emit(‘rechercher’, { … }); }] [emit(‘rechercher’, ...)] triggers the event—the direct equivalent of [rechercher.emit(...)] on the [Angular] side, and of the function call received as a prop ([onRechercher(...)]) on the [React] side;
  • line 36: [select id="select-medecin" class="form-select" v-model="idMedecinSelectionne"] [v-model] on a <select> directly links the selected value to [idMedecinSelectionne.value]—a simplification compared to [React] (separate :value and @change, see previous chapter) and to [Angular] ([(ngModel)] or [value]+(change), as appropriate);
  • lines 38–40: [option v-for="medecin in medecins" :key="medecin.id" :value="medecin.id"] [v-for] + [:key] is the[Vue.js] equivalent of [.map(...)] + [key={...}] on the [React] side (and [@for (medecin of medecins(); track medecin.id)] on the [Angular] side): in all three cases, a stable identifier specifies how to recognize a doctor that is already displayed if it is redrawn.

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

Port of [agenda.component.ts/.html] ([Angular]) and [Agenda.tsx] ([React]): displays a doctor’s schedule for a given day. Equivalent to examples 8 and 9 of the original AngularJS 1.x client. Since authentication was added, this component also receives [peutModifier]: when set to false (role USER), the “Book”/“Cancel” buttons disappear—the schedule remains viewable, but in read-only mode.

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

Let’s comment on this code:

  • lines 34, 36: [p v-if="agenda === null" … div v-else …][v-if]/[v-else] replace the “early return” used in [React] ([if (agenda === null) return <p>...</p>;]): as long as no search has been performed (or the calendar is empty), only the empty status message is included in DOM, without even constructing the table that follows;
  • line 7: [peutModifier: boolean;] — a new feature introduced by authentication, similar to [Angular] and [React]: App.vue sends [auth.estAdmin] to it (see below). When set to false (role USER), the “Reserve”/“ Cancel” button doesn’t even exist in DOM (line 67, [v-if="peutModifier"])—it’s not just grayed out or disabled, it’s completely missing;
  • Lines 58–64: [span v-if="creneauAgenda.rv === null" … span v-else …][v-if]/[v-else] replace the ternary operator used on the [React] side—green [Bootstrap] badge (“Available”/“ Free”) or gray (patient’s name), depending on the slot’s status;
  • line 67: [button v-if="peutModifier" type="button"] — unlike [React] (the operator [&&], {peutModifier && (…)}), [v-if] alone is sufficient here in [Vue.js]: there is no [Vue.js] equivalent of [&&] to use for “display only if true, otherwise nothing ”—[v-if] does exactly that on its own;
  • line 70: [{{ creneauAgenda.rv === null ? t('AGENDA.BOOK') : t('AGENDA.CANCEL_APPOINTMENT') }}] — like the [Angular] side (where a pipe can be used in any template expression) and the [React] side, [t(...)] is a standard JavaScript function: it can be used directly in a [{{ }}] expression, without any special syntax.

This component does not make any HTTP calls itself: it displays data received via props and notifies its parent (App.vue) of the user’s intentions via the [reserver]/[annuler] events —exactly the same separation of responsibilities as between [Angular] and [React].

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

Port of [booking-dialog.component.ts/.html] ([Angular]) and [BookingDialog.tsx] ([React]): the (modal) window that allows you to select a patient to book an available time slot. Equivalent to Example 9 from the original AngularJS and 1.x client.

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

Let’s break down this code:

  • line 35: [template v-if="ouvert"] — a [<template>] containing (a tag specific to [Vue.js], which produces no ELLE-MÊME elements) contains the [v-if]: nothing is even loaded into DOM until the modal is opened—the exact equivalent of [if (!ouvert) return null;] on the [React] side, and of [@if(ouvert())] on the [Angular] side;
  • line 36: [div class="modal-backdrop fade show"] — the semi-transparent background that darkens the rest of the page—hard-coded here, as in the other two variants, whereas [Bootstrap] usually inserts it itself via JavaScript when the modal is displayed;
  • line 38: [div class="modal fade show d-block"][d-block] takes over the role normally performed by JavaScript from [Bootstrap] (add [display:block] upon opening) - Here, it is the [v-if] from line 32 that directly fulfills this role;
  • line 44: [:aria-label="t(‘BOOKING_DIALOG.CLOSE_ARIA’)"] — unlike [attr.aria-label="‘BOOKING_DIALOG.CLOSE_ARIA’ | translate"] on the [Angular] side (an attribute binding distinct from the {{ }} interpolation), and much like [JSX] and [React] (which make no distinction between an attribute and an element’s content), [Vue.js] uses [:aria-label="..."] here —the prefix [:] indicating an attribute LIAISON (a JavaScript expression) rather than a literal string.

As in the other two variants, this component uses the VRAIES and [Bootstrap] classes of a modal, but its visibility is controlled by [Vue.js] (a [v-if] on the [ouvert] property) rather than by the JavaScript of [Bootstrap] ([bootstrap.bundle.js, new bootstrap.Modal(...)])—to prevent them from each managing the same information independently (is the modal open?), a common source of subtle bugs. The original document (2014) used a component from the angular-ui-bootstrap library (a “turnkey” modal), already designed according to this same principle.

6.11. The root App component: orchestrator and authentication guardian

This component fulfills the role played by the “main controller” in the original AngularJS 1.x application, as well as by the App components on the [Angular] and [React] sides: it maintains the application’s global state and responds to events from the features/ components to call [useRdvService()] at the right time. Since the addition of authentication, it has taken on a second role: that of a “gatekeeper” on the presentation layer, which decides whether to display the login screen or the rest of the application. A [Vue.js] component combines into a single [.vue] file what [Angular] separates into three (app.ts, app.html, app.css)—exactly as App.tsx already did for [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>

Let’s comment on this code:

  • lines 25–26: [const dernierIdMedecin = ref<number | null>(null); const dernierJour = ref<string | null>(null);] — we store the last search (doctor + day) so we can refresh the calendar after a booking or cancellation. Like [React] (a [useRef](), not a [useState]()), these two values are never used directly for display (unlike the calendar or error pages)—but, unlike [React], [Vue.js] does not require a special hook ([useRef]) for a variable that survives rendering without triggering a redraw: since a [<script setup>] runs only once per component instance (unlike a component function [React], which is re-executed on every render), a simple variable JavaScript (let) would have worked just as well; [ref()] is used here simply for consistency with the rest of the file;
  • lines 28–36: [watch(() => auth.estConnecte, (estConnecte) => { if (estConnecte) { chargerListesInitiales(); } }, { immediate: true });] — re-executes its body every time [auth.estConnecte] changes — the direct equivalent of [effect(() => { if (this.auth.estConnecte()) { … } })] on the [Angular] side, and of [useEffect](..., [auth.estConnecte]) on the [React] side. [{ immediate: true }] also causes it to run immediately the first time, without waiting for a change—exactly the behavior of [useEffect]() on the [React] side (which always runs at least once, on initialization): if a session was already stored in [localStorage] (page reload), the lists load immediately upon startup, without waiting for a login;
  • lines 112–133: [div class="btn-group btn-group-sm" … FR … EN … template v-if="auth.estConnecte" …] — the language selector is visible in TOUJOURS (including on the login screen); the role badge + name + logout button, however, are only displayed if [auth.estConnecte] (lines 126–132, [template v-if="auth.estConnecte"])—exactly the same logic as for [Angular] and [React];
  • line 152: [Login v-if="!auth.estConnecte"] — as long as [useAuth().estConnecte] is false, this component displays only <Login />; since all server routes are now protected by [JwtAuthGuard] (Chapter 3), it would be pointless anyway (and cause 401 errors) to load doctors/clients/calendar before logging in—exactly the same reasoning as for [Angular] and [React];
  • Lines 154–171: [template v-else … DoctorDayPicker … Agenda … BookingDialog …] — the three “business logic” components from features/ are assembled here, each receiving its props (data, prefixed with [:]) and its event handlers (prefixed with [@]) - the exact equivalent of the app.html template on the [Angular] side, where the same three components appeared with [medecins]="doctors()" [(rechercher)="onRechercherAgenda($event)"]…

State ([ref]), effect ([watch]), and event handlers (the onXxx functions): these are exactly the same components already presented in the previous chapter, applied here to the entire root component—nothing new has been introduced in this file; it has simply been assembled.

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

The following screenshots were obtained using exactly the same sequence of actions as the [Angular] and [React] variants in this course, on this client [Vue.js]: URL on the server left at its default value (http://localhost:8080), demo accounts admin/admin and user/user.

6.12.1. 1. Login screen

When the page first loads, [useAuth().estConnecte] is set to false (no session in localStorage): only <Login /> is displayed.

Image

Login screen

An attempt with an invalid username/password combination displays the error message stored by Login.vue:

Image

Login error

The FR/EN selector, which remains visible, also translates this screen (and the stored error message, which is retranslated without additional code; see Login.vue above):

Image

Login screen in English

6.12.2. 2. Log in as an administrator (role ADMIN, full access)

Once logged in with admin/admin, App.vue stops displaying <Login /> and loads doctors/clients ([chargerListesInitiales()], triggered by [watch(() => auth.estConnecte, …, { immediate: true })] on line 28):

Image

Home page, once logged in via ADMIN

Image

Home page, after logging in via ADMIN, in English

After selecting a doctor and a date in <DoctorDayPicker />, the calendar is displayed with the “Book”/“Cancel” buttons (peutModifier is set to true for role ADMIN):

Image

Calendar, view ADMIN (Book/Cancel buttons visible)

Clicking “Reserve” opens <BookingDialog /> ([ouvert] becomes true; see [onDemandeReservation] in App.vue):

Image

Booking window (Bootstrap modal)

After confirmation ([onConfirmerReservation] calls [rdv.ajouterRv(...)], then [chargerAgenda()] refreshes the display), the time slot appears as booked:

Image

Calendar after booking

Clicking “Cancel” ([onAnnulerRv] calls [rdv.supprimerRv(...)]) makes the time slot available again:

Image

Calendar after cancellation

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

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

Image

Home page, once logged in as USER

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

Image

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

6.13. What remains outside the scope or has been deferred

The "debug" mode (display of the raw template for the current view) of the original AngularJS and 1.x client is a feature that has been deliberately omitted from this port, just as in the [Angular] and [React] variants of this course—it will not appear in a later stage of the course (see Chapter 6, Conclusion, for the rationale behind this decision). The configuration of an artificial network delay, however, is indeed deferred to a later stage in all three variants.

Two additional differences, specific to this [Vue.js] port, are worth mentioning here:

  • unit tests (which are absent from this project, as well as from projects [Angular] and [React] in this course) would follow a different approach in [Vue.js]: rather than TestBed (specific to [Angular]) or [React Testing Library] ([React]), the [Vue.js] ecosystem typically relies on [Vue Test Utils]—not presented in this document;
  • type checking for [<template>] has a special status here: unlike [React] (where the standard TypeScript compiler suffices, since [JSX] is just another instance of JavaScript), [Vue.js] produces [vue-tsc] (see package.json, Chapter 5), specifically POUR, which checks the types within the [<template>] - a situation that, on this specific point, is closer to [Angular] and strictTemplates than to [React], although it is a separate tool rather than an option of the compiler itself.