Skip to content

5. Le routage

Une application a plusieurs écrans. Sur le web, chaque écran a une adresse (/ex17/medecins/2) : on peut le désigner, l'ouvrir depuis un lien, le recharger, le protéger par des gardes. [Angular] gère ces adresses avec son routeur (le paquet [@angular/router]), que nous utilisons depuis le premier exemple : la page d'accueil de l'application des exemples associe l'adresse /ex01 à la page de l'exemple 01, /ex02 à celle de l'exemple 02, etc. [Ionic] ne remplace pas ce routeur : il s'appuie sur lui, et y ajoute ce qu'attend une application mobile. Son composant [<ion-router-outlet>] fait des pages une pile : une nouvelle page est empilée au-dessus de la précédente (avec l'animation d'Android), le bouton Retour du téléphone la dépile, et la page précédente, restée en mémoire, réapparaît telle qu'on l'avait laissée. C'est le [Navigator] de [Flutter] et le paquet [go_router] réunis.

5.1. Exemple 17 : le routeur

L'exemple 17 a six pages : un accueil, la liste des médecins, le détail d'un médecin, une recherche, une page « À propos » chargée à la demande, et une page pour les adresses inconnues. Les cinq premières sont réunies dans une « coquille », avec une barre d'onglets en bas de l'écran (quatre onglets : le détail d'un médecin appartient à l'onglet Médecins).

5.1.1. Les données

// =========================================================================
// src/app/ex17-routeur/donnees.ts - les données de l'exemple 17
// -------------------------------------------------------------------------
// Des données "en dur", pour ne pas dépendre d'un serveur dans cet exemple.
// =========================================================================

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

export const medecins: Medecin[] = [
  { id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste' },
  { id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre' },
  { id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'généraliste' },
];

Des données « en dur », pour ne pas dépendre d'un serveur dans cet exemple : un tableau d'objets décrits par l'interface [Medecin].

5.1.2. Les routes

// =========================================================================
// src/app/ex17-routeur/routes.ts - les ROUTES de l'exemple 17
// -------------------------------------------------------------------------
// Une application a plusieurs écrans (des "pages"). Le ROUTEUR d'Angular
// associe une ADRESSE (un chemin, comme sur le web : /ex17/medecins/2) à
// une page. Ionic s'appuie sur lui : son <ion-router-outlet> en fait une
// PILE de pages (on empile, on dépile, le bouton Retour d'Android dépile).
// Ces routes sont les ENFANTS de la route /ex17 de app.routes.ts
// (loadChildren) : leurs chemins sont relatifs à /ex17.
//   'medecins/:id'   :id est un paramètre (/ex17/medecins/2 -> id = "2") ;
//   children         des routes affichées DANS la "coquille" (la barre du
//                    haut, la barre d'onglets du bas) ;
//   redirectTo       une redirection : l'ancienne adresse mène à la nouvelle ;
//   '**'             le "joker" : toute adresse qu'aucune route ne reconnaît ;
//   title            le titre du document (onglet du navigateur) ;
//   loadComponent    chargement DIFFÉRÉ : le code de la page n'est
//                    téléchargé qu'à sa première visite.
// =========================================================================

import { ResolveFn, Routes } from '@angular/router';
import { Coquille } from './coquille';
import { medecins } from './donnees';
import { Accueil } from './pages/accueil.page';
import { DetailMedecin } from './pages/detail-medecin.page';
import { ListeMedecins } from './pages/liste-medecins.page';
import { PageIntrouvable } from './pages/page-introuvable.page';
import { Recherche } from './pages/recherche.page';

// le titre de la page d'un médecin, calculé d'après le paramètre :id de l'adresse
const titreMedecin: ResolveFn<string> = (route) => {
  const medecin = medecins.find((m) => m.id === Number(route.paramMap.get('id')));
  return medecin ? `${medecin.prenom} ${medecin.nom}` : 'Médecin inconnu';
};

export const routes: Routes = [
  {
    // la coquille (Coquille) contient la barre d'onglets ; ses routes
    // enfants sont affichées DANS la coquille, au-dessus de la barre
    path: '',
    component: Coquille,
    children: [
      // /ex17 tout court : on va à l'onglet d'accueil
      { path: '', redirectTo: 'accueil', pathMatch: 'full' },
      { path: 'accueil', title: 'Accueil', component: Accueil },
      { path: 'medecins', title: 'Médecins', component: ListeMedecins },
      // une page à PARAMÈTRE : /ex17/medecins/2 ; elle commence par
      // 'medecins' : elle appartient à l'onglet Médecins, dont elle
      // empile une page au-dessus de la liste
      { path: 'medecins/:id', title: titreMedecin, component: DetailMedecin },
      // /ex17/recherche?q=pédiatre : le paramètre q est un paramètre de REQUÊTE
      { path: 'recherche', title: 'Recherche', component: Recherche },
      // chargement différé : import() est une importation DYNAMIQUE
      // (une Promise) ; le code de la page est placé dans un fichier à part
      {
        path: 'a-propos',
        title: 'À propos',
        loadComponent: () => import('./pages/a-propos.page').then((m) => m.APropos),
      },
      // une redirection : la route n'a pas de page, elle renvoie à une autre adresse
      { path: 'docteurs', redirectTo: 'medecins' },
    ],
  },
  // toute autre adresse sous /ex17 : la page introuvable, HORS de la coquille
  { path: '**', title: 'Page introuvable', component: PageIntrouvable },
];

Une route associe un chemin à une page. Les routes de l'exemple 17 sont rangées dans son dossier, et la route /ex17 de l'application les charge par [loadChildren] (plus loin) : ce sont des routes enfants de /ex17, et leurs chemins sont relatifs à /ex17 ([accueil] désigne l'adresse /ex17/accueil).

  • lignes 30-33 : une fonction de type [ResolveFn<string>], qui calcule le titre de la page d'un médecin d'après le paramètre [:id] de l'adresse ([route.paramMap.get('id')]). La propriété [title] d'une route est le titre du document (l'onglet du navigateur sur le web) : une chaîne, ou une telle fonction. C'est le pendant du widget [Title] de [Flutter] ;
  • lignes 36-41 : la coquille de l'exemple. La route n'a pas de chemin propre ([path: '']) ; elle affiche le composant [Coquille] (la barre du haut et la barre d'onglets), et ses routes enfants ([children]) sont affichées DANS la coquille. C'est l'équivalent de la [ShellRoute] de [go_router] ;
  • ligne 43 : l'adresse /ex17 tout court est redirigée vers /ex17/accueil. [pathMatch: 'full'] : la route ne s'applique que si l'adresse restante est vide (sans cela, le chemin vide, qui est le début de toute adresse, s'appliquerait toujours) ;
  • lignes 44-45 : une route ordinaire : son chemin, son titre, et le composant de sa page ;
  • ligne 49 : une route à paramètre : [medecins/:id], où [:id] est un paramètre ([/ex17/medecins/2] : [id] vaut ["2"], toujours une chaîne). Avec [go_router], cette route était imbriquée dans celle de la liste ; ici, elle est sa voisine. Son chemin commence par [medecins] : pour [Ionic], elle appartient à l'onglet Médecins, et sa page est empilée au-dessus de la liste, dans la pile de cet onglet ;
  • ligne 51 : la recherche. Ses paramètres de requête (après le [?] : [/ex17/recherche?q=pédiatre]) ne figurent pas dans le chemin de la route : toute route peut en recevoir ;
  • lignes 54-58 : le chargement différé. [loadComponent] est une fonction qui rend une [Promise] : [import('./pages/a-propos.page')] est une importation dynamique de [JavaScript], qui charge le fichier quand on l'exécute, et [then] en extrait le composant. La compilation ([ng build]) place alors le code de la page dans un fichier [JavaScript] à part, téléchargé à la première visite de la page. C'est le pendant de l'importation [deferred as] de [Dart] ;
  • ligne 60 : une redirection : la route n'a pas de page, elle renvoie à une autre adresse. [redirectTo] peut aussi être une fonction, qui calcule l'adresse ;
  • ligne 64 : la route joker [**] reconnaît toute adresse qu'aucune route précédente n'a reconnue (le routeur essaie les routes dans l'ordre). Elle est placée hors de la coquille : la page introuvable a sa propre barre du haut. C'est le pendant de l'[errorBuilder] de [go_router].

5.1.3. Le démarrage

Le routeur est installé une fois pour toutes, au démarrage de l'application, dans le fichier [main.ts] :

    // le routeur : la liste des routes (app.routes.ts) ;
    // withComponentInputBinding : les paramètres d'une route sont donnés aux
    // entrées (input) du composant de la page (cf. exemple 17)
    provideRouter(routes, withComponentInputBinding()),
  • ligne 32 : [provideRouter] reçoit la liste des routes de l'application ; l'option [withComponentInputBinding()] donne les paramètres d'une route (ceux du chemin, comme [:id], et ceux de la requête, comme [?q=]) aux entrées ([input()]) de même nom du composant de la page. C'est ce qui remplace, ici, le [state.pathParameters['id']] de [go_router].

Et la route /ex17 de l'application (fichier [app.routes.ts]) charge les routes de l'exemple :

  // les exemples 17 et 18 ont leurs propres routes, "enfants" de /ex17 et /ex18
  { path: 'ex17', loadChildren: () => import('./ex17-routeur/routes').then((m) => m.routes) },
  { path: 'ex18', loadChildren: () => import('./ex18-gardes/routes').then((m) => m.routes) },
  • ligne 31 : [loadChildren] : comme [loadComponent], une importation dynamique ; le fichier [routes.ts] de l'exemple, et tout ce qu'il importe, n'est téléchargé qu'à la première visite d'une adresse commençant par /ex17.

5.1.4. La coquille de l'application

// =========================================================================
// src/app/ex17-routeur/coquille.ts - la "coquille" de l'exemple 17
// -------------------------------------------------------------------------
// La barre du haut, la barre d'onglets du bas, et, entre les deux, la page
// de la route courante. C'est le composant <ion-tabs> d'Ionic :
//   - <ion-tab-bar slot="bottom"> : la barre d'onglets, en bas ;
//   - <ion-tab-button tab="medecins"> : un onglet ; le toucher mène à
//     l'adresse /ex17/medecins. L'onglet actif est celui dont le nom est le
//     premier segment de l'adresse (après /ex17) : /ex17/medecins/2 -> Médecins ;
//   - chaque onglet a sa PROPRE pile de pages : on peut entrer dans le
//     détail d'un médecin, passer à l'onglet Recherche, revenir à l'onglet
//     Médecins... et retrouver le détail.
// =========================================================================

import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
  IonBackButton, IonButtons, IonHeader, IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs, IonTitle, IonToolbar,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { home, informationCircle, people, search } from 'ionicons/icons';
import { ChargementDiffere } from './pages/chargement-differe';

// les onglets : le nom de l'onglet (= le chemin, après /ex17), le libellé, l'icône
const onglets = [
  { tab: 'accueil', libelle: 'Accueil', icone: 'home' },
  { tab: 'medecins', libelle: 'Médecins', icone: 'people' },
  { tab: 'recherche', libelle: 'Recherche', icone: 'search' },
  { tab: 'a-propos', libelle: 'À propos', icone: 'information-circle' },
];

@Component({
  selector: 'app-ex17-coquille',
  imports: [
    IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle,
    ChargementDiffere,
  ],
  templateUrl: './coquille.html',
  styles: `
    .adresse {
      background: var(--ion-color-light);
      color: var(--ion-color-light-contrast);
      padding: 8px;
      font-family: monospace;
      font-size: 12px;
    }
  `,
})
export class Coquille {
  readonly onglets = onglets;
  private readonly router = inject(Router);

  // l'adresse courante. lastSuccessfulNavigation est un SIGNAL du routeur :
  // la dernière navigation réussie ; finalUrl : son adresse, après les
  // redirections. decodeURIComponent : %C3%A9 redevient é
  readonly adresse = computed(() => {
    const navigation = this.router.lastSuccessfulNavigation();
    return navigation?.finalUrl ? decodeURIComponent(this.router.serializeUrl(navigation.finalUrl)) : '';
  });

  constructor() {
    // les icônes utilisées par le gabarit
    addIcons({ home, people, search, informationCircle });
  }
}
<!-- ======================================================================
     coquille.html - la coquille de l'exemple 17 : <ion-tabs>
     ====================================================================== -->

<ion-tabs>
  <!-- slot="top" : placé AU-DESSUS des pages des onglets -->
  <ion-header slot="top">
    <ion-toolbar color="primary">
      <ion-buttons slot="start">
        <!-- ce bouton Retour-là quitte l'exemple : il dépile la coquille -->
        <ion-back-button defaultHref="/" />
      </ion-buttons>
      <ion-title>Exemple 17 - le routeur</ion-title>
    </ion-toolbar>
    <div class="adresse">adresse courante : {{ adresse() }}</div>
    <!-- une barre de progression, pendant le chargement d'une page différée -->
    <app-chargement-differe />
  </ion-header>

  <!-- ici, <ion-tabs> place lui-même un <ion-router-outlet> : la page de
       la route enfant courante -->

  <!-- la barre d'onglets, en bas : typique d'une application mobile -->
  <ion-tab-bar slot="bottom">
    @for (onglet of onglets; track onglet.tab) {
      <ion-tab-button [tab]="onglet.tab">
        <ion-icon [name]="onglet.icone" />
        <ion-label>{{ onglet.libelle }}</ion-label>
      </ion-tab-button>
    }
  </ion-tab-bar>
</ion-tabs>

La coquille est le composant [<ion-tabs>] d'[Ionic] : une barre d'onglets, et, au-dessus, la page de l'onglet choisi.

  • lignes 25-30 (coquille.ts) : les onglets : un nom (qui est aussi le chemin de la route, après /ex17), un libellé et une icône ;
  • lignes 56-59 (coquille.ts) : l'adresse courante, un signal calculé ([computed]). Le routeur expose des signaux : [lastSuccessfulNavigation()], la dernière navigation réussie, et [currentNavigation()], la navigation en cours (plus loin). [finalUrl] est l'adresse atteinte, après les redirections ; [serializeUrl] la met sous forme de chaîne ; [decodeURIComponent] décode les caractères encodés ([%C3%A9] redevient [é]). Le signal étant relu à chaque navigation, l'adresse affichée suit ;
  • ligne 63 (coquille.ts) : les icônes utilisées par le gabarit ;
  • lignes 7-18 (coquille.html) : la barre du haut. [<ion-tabs>] place les éléments marqués [slot="top"] au-dessus des pages des onglets, et les autres en dessous. Le bouton Retour de cette barre-là dépile la coquille tout entière : il ramène au menu des exemples ;
  • ligne 15 (coquille.html) : l'adresse courante ;
  • ligne 17 (coquille.html) : une barre de progression, affichée pendant les navigations (plus loin) ;
  • lignes 20-21 (coquille.html) : [<ion-tabs>] place lui-même, entre les deux barres, un [<ion-router-outlet>] : la page de la route enfant courante. On ne l'écrit pas ;
  • lignes 24-31 (coquille.html) : la barre d'onglets du bas ([<ion-tab-bar slot="bottom">]), typique d'une application mobile, le pendant de la [NavigationBar] de [Flutter]. Chaque [<ion-tab-button [tab]="...">] est un onglet ; le toucher mène à l'adresse /ex17/<nom de l'onglet>.

Deux différences avec [go_router] :

  • l'onglet actif n'est pas calculé par le code : [<ion-tabs>] le déduit de l'adresse (l'onglet dont le nom est le premier segment de l'adresse, après /ex17 : [/ex17/medecins/2] désigne l'onglet [medecins]) ;
  • chaque onglet a sa propre pile de pages. On entre dans le détail d'un médecin, on passe à l'onglet Recherche, on revient à l'onglet Médecins : on retrouve le détail du médecin, et le bouton Retour ramène à la liste. C'est le comportement habituel des applications mobiles (celles d'[Android] et d'[iOS]).

5.1.5. Les pages

Les pages des onglets n'ont qu'un [<ion-content>] : leur barre du haut est celle de la coquille.

// =========================================================================
// src/app/ex17-routeur/pages/accueil.page.ts - l'onglet d'accueil
// -------------------------------------------------------------------------
// routerLink : un LIEN vers une adresse ; un appui y mène, comme un lien
// <a href> sur le web, mais sans recharger l'application.
// =========================================================================

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { IonButton, IonContent } from '@ionic/angular';

@Component({
  selector: 'app-ex17-accueil',
  imports: [RouterLink, IonContent, IonButton],
  templateUrl: './accueil.page.html',
})
export class Accueil {}
<!-- ======================================================================
     accueil.page.html - l'onglet d'accueil de l'exemple 17
     ====================================================================== -->

<!-- une page d'onglet n'a que son contenu : la barre du haut est celle de la coquille -->
<ion-content class="ion-padding">
  <p>
    Bienvenue. Touchez les onglets : l'écran change, et son adresse aussi. Sur le web, l'adresse
    s'affiche dans la barre du navigateur, les boutons Précédent et Suivant fonctionnent, ainsi que
    le rechargement (F5) de n'importe quelle page.
  </p>
  <!-- des liens vers des adresses particulières -->
  <ion-button fill="outline" routerLink="/ex17/docteurs">/ex17/docteurs (redirection)</ion-button>
  <ion-button fill="outline" routerLink="/ex17/page-inconnue">Lien cassé</ion-button>
</ion-content>
  • lignes 13-14 (html) : deux liens. La directive [routerLink] d'[Angular] (importée ligne 9 du .ts) transforme n'importe quel élément en lien : un appui mène à l'adresse indiquée, comme un lien [<a href>] sur le web, mais sans recharger l'application. Le premier mène à la redirection /ex17/docteurs ; le second, à une adresse qui n'existe pas.
// =========================================================================
// src/app/ex17-routeur/pages/liste-medecins.page.ts - des liens avec paramètre
// -------------------------------------------------------------------------
// [routerLink]="['/ex17/medecins', m.id]" : l'adresse est fabriquée à partir
// d'un tableau de segments : /ex17/medecins/2. La page du médecin est
// EMPILÉE au-dessus de la liste : le bouton Retour y revient.
// =========================================================================

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { IonContent, IonItem, IonLabel, IonList } from '@ionic/angular';
import { medecins } from '../donnees';

@Component({
  selector: 'app-ex17-liste-medecins',
  imports: [RouterLink, IonContent, IonList, IonItem, IonLabel],
  templateUrl: './liste-medecins.page.html',
})
export class ListeMedecins {
  readonly medecins = medecins;
}
<!-- ======================================================================
     liste-medecins.page.html - l'onglet Médecins de l'exemple 17
     ====================================================================== -->

<ion-content>
  <ion-list>
    @for (m of medecins; track m.id) {
      <!-- button : l'élément réagit au toucher ; detail : la flèche > à droite -->
      <ion-item button detail [routerLink]="['/ex17/medecins', m.id]">
        <ion-label>{{ m.titre }} {{ m.prenom }} {{ m.nom }}</ion-label>
      </ion-item>
    }
  </ion-list>
</ion-content>
  • ligne 9 (html) : un élément de liste cliquable ([button]), avec une flèche à droite ([detail]). Son adresse est fabriquée à partir d'un tableau de segments : [[routerLink]="['/ex17/medecins', m.id]"] donne /ex17/medecins/2. La page du médecin est EMPILÉE au-dessus de la liste (une navigation « en avant ») : le bouton Retour y revient.
// =========================================================================
// src/app/ex17-routeur/pages/detail-medecin.page.ts - une page à paramètre
// -------------------------------------------------------------------------
// Le routeur donne le paramètre :id de l'adresse à l'ENTRÉE id de la page
// (input), une CHAÎNE ("2") : c'est withComponentInputBinding() (main.ts).
// Si l'on passe de /ex17/medecins/1 à /ex17/medecins/2, Angular garde la
// même page et change seulement la valeur de l'entrée : le médecin
// affiché, calculé (computed) à partir d'elle, suit.
// Navigation par le CODE : NavController, le service de navigation d'Ionic.
// =========================================================================

import { Component, computed, inject, input } from '@angular/core';
import { IonButton, IonContent, IonRouterOutlet, IonText, NavController } from '@ionic/angular';
import { medecins } from '../donnees';

@Component({
  selector: 'app-ex17-detail-medecin',
  imports: [IonContent, IonButton, IonText],
  templateUrl: './detail-medecin.page.html',
})
export class DetailMedecin {
  // le paramètre :id de l'adresse
  readonly id = input.required<string>();

  // Number("abc") vaut NaN : aucun médecin ne sera trouvé (undefined)
  readonly medecin = computed(() => medecins.find((m) => m.id === Number(this.id())));
  // le numéro du médecin suivant : 1 -> 2 -> 3 -> 1
  readonly suivant = computed(() => ((this.medecin()?.id ?? 0) % medecins.length) + 1);

  private readonly navCtrl = inject(NavController);
  // la pile de pages de l'onglet (le <ion-router-outlet> qui affiche cette page)
  private readonly outlet = inject(IonRouterOutlet);

  retour(): void {
    // canGoBack : y a-t-il une page en dessous ? (pas si l'on est arrivé
    // directement à cette adresse, sur le web)
    if (this.outlet.canGoBack()) {
      this.outlet.pop();
    } else {
      this.navCtrl.navigateBack('/ex17/medecins');
    }
  }

  medecinSuivant(): void {
    // replaceUrl : l'adresse REMPLACE l'adresse courante dans l'historique
    // (pas d'empilement : le bouton Retour ramène à la liste)
    this.navCtrl.navigateForward(['/ex17/medecins', this.suivant()], { replaceUrl: true });
  }
}
<!-- ======================================================================
     detail-medecin.page.html - la page d'un médecin (exemple 17)
     ====================================================================== -->

<ion-content class="ion-padding">
  <!-- @if (...; as m) : la valeur du test, nommée m, dans le bloc -->
  @if (medecin(); as m) {
    <h2>{{ m.titre }} {{ m.prenom }} {{ m.nom }}</h2>
    <p>Spécialité : {{ m.specialite }}</p>
    <ion-button fill="outline" (click)="retour()">Retour</ion-button>
    <ion-button (click)="medecinSuivant()">Médecin suivant</ion-button>
  } @else {
    <ion-text color="danger">Le médecin n° {{ id() }} n'existe pas.</ion-text>
  }
</ion-content>
  • ligne 23 (.ts) : l'entrée [id], qui reçoit le paramètre [:id] de l'adresse ([withComponentInputBinding]). [input.required<string>()] : une entrée obligatoire, de type chaîne ;
  • ligne 26 (.ts) : le médecin, un signal calculé à partir de l'entrée. [Number("abc")] vaut [NaN], qui n'est égal à aucun nombre : un [id] incorrect donne [undefined] (c'était [int.tryParse] avec [Dart]). Si l'on passe de /ex17/medecins/1 à /ex17/medecins/2, [Angular] garde la même page et change seulement la valeur de l'entrée : le médecin affiché suit ;
  • ligne 28 (.ts) : le numéro du médecin suivant (1, 2, 3, puis 1) ;
  • ligne 30 (.ts) : [NavController], le service de navigation d'[Ionic] : la navigation par le CODE ;
  • ligne 32 (.ts) : [IonRouterOutlet] : la pile de pages qui affiche cette page (celle de l'onglet Médecins) ;
  • lignes 34-42 (.ts) : le bouton Retour de la page. S'il y a une page en dessous ([canGoBack()]), on la dépile ([pop()]) ; sinon (sur le web, on a pu arriver directement à cette adresse), on va à la liste, avec une animation « en arrière » ([navigateBack]) ;
  • lignes 44-48 (.ts) : le médecin suivant : une navigation « en avant » ([navigateForward]), mais [replaceUrl: true] : la nouvelle adresse REMPLACE l'adresse courante dans l'historique. Pas d'empilement : le bouton Retour ramène à la liste, pas au médecin précédent (c'était [context.pushReplacement] avec [go_router]) ;
  • lignes 7-14 (html) : [@if (medecin(); as m)] : si le médecin existe, il est nommé [m] dans le bloc ; sinon ([@else]), un message.

Avec [Ionic], on navigue donc de trois façons :

  • par un lien : [routerLink] ;
  • par le routeur d'[Angular] : [router.navigate([...])] ou [router.navigateByUrl('...')] ;
  • par le [NavController] d'[Ionic] : [navigateForward] (empiler une page, c'était [context.push]), [navigateBack] (revenir en arrière, en dépilant les pages qui sont au-dessus), [navigateRoot] (repartir d'une pile neuve, c'était [context.go]), [back()] (comme le bouton Retour). Ces méthodes appellent le routeur d'[Angular], en précisant le SENS de la navigation, dont dépendent l'animation et la gestion de la pile.
// =========================================================================
// src/app/ex17-routeur/pages/recherche.page.ts - une page pilotée par l'adresse
// -------------------------------------------------------------------------
// /ex17/recherche?q=pédiatre : la page est entièrement décrite par son
// adresse. Sur le web, on peut la mettre en favori, la recharger, revenir
// en arrière. Rechercher, c'est ALLER à l'adresse /ex17/recherche?q=... :
// la page affiche ce que dit l'adresse, et rien d'autre.
// Le paramètre de REQUÊTE q (après le ?) est donné, lui aussi, à l'entrée
// de même nom (withComponentInputBinding) ; il vaut undefined s'il est absent.
// =========================================================================

import { Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { IonContent, IonItem, IonLabel, IonList, IonSearchbar } from '@ionic/angular';
import { medecins } from '../donnees';

@Component({
  selector: 'app-ex17-recherche',
  imports: [IonContent, IonSearchbar, IonList, IonItem, IonLabel],
  templateUrl: './recherche.page.html',
})
export class Recherche {
  // le paramètre ?q=... de l'adresse
  readonly q = input<string>();

  // les résultats : recalculés quand l'adresse (donc q) change
  readonly resultats = computed(() => {
    const texte = (this.q() ?? '').toLowerCase();
    return medecins.filter((m) => `${m.prenom} ${m.nom} ${m.specialite}`.toLowerCase().includes(texte));
  });

  private readonly router = inject(Router);

  rechercher(saisie: string): void {
    // queryParams : le routeur fabrique l'adresse, en encodant ce qu'il
    // faut (é -> %C3%A9)
    this.router.navigate(['/ex17/recherche'], { queryParams: { q: saisie.trim() } });
  }
}
<!-- ======================================================================
     recherche.page.html - l'onglet Recherche de l'exemple 17
     ====================================================================== -->

<ion-content class="ion-padding">
  <!-- un champ NON contrôlé : [value] lui donne la valeur de l'adresse (et
       la lui redonne si l'adresse change : bouton Précédent du navigateur) ;
       #champ : une référence au composant, pour lire sa valeur ;
       enterkeyhint="search" : la touche Entrée du clavier devient une loupe -->
  <ion-searchbar
    #champ
    placeholder="nom ou spécialité"
    enterkeyhint="search"
    [value]="q() ?? ''"
    (keyup.enter)="rechercher(champ.value ?? '')"
  />
  <ion-list>
    @for (m of resultats(); track m.id) {
      <ion-item>
        <ion-label>{{ m.prenom }} {{ m.nom }} ({{ m.specialite }})</ion-label>
      </ion-item>
    } @empty {
      <ion-item>
        <ion-label color="medium">Aucun résultat.</ion-label>
      </ion-item>
    }
  </ion-list>
</ion-content>
  • ligne 24 (.ts) : l'entrée [q] reçoit le paramètre de requête [?q=...]. Il peut manquer : l'entrée vaut alors [undefined], d'où le type [input<string>()] (une chaîne ou [undefined]) et les [?? ''] ;
  • lignes 27-30 (.ts) : les résultats, recalculés quand l'adresse (donc [q]) change ;
  • lignes 34-38 (.ts) : rechercher, c'est ALLER à l'adresse /ex17/recherche?q=<saisie>. [router.navigate] reçoit le chemin et, dans [queryParams], les paramètres de la requête ; il fabrique l'adresse en encodant ce qu'il faut ([é] devient [%C3%A9]). La page est entièrement décrite par son adresse : sur le web, on peut la mettre en favori, la recharger, revenir en arrière ;
  • lignes 10-16 (html) : une barre de recherche d'[Ionic] ([<ion-searchbar>], avec sa loupe et son bouton d'effacement). C'est un champ NON contrôlé : [[value]] lui donne la valeur de l'adresse, et la lui redonne si l'adresse change (bouton Précédent du navigateur), comme la clé [ValueKey(q)] de [Flutter]. [#champ] est une variable de référence du gabarit : elle désigne le composant, dont on lit la valeur ([champ.value]) quand on appuie sur Entrée ([(keyup.enter)]). [enterkeyhint="search"] : la touche Entrée du clavier du téléphone devient une loupe ;
  • lignes 18-26 (html) : les résultats ; [@empty] : le contenu affiché quand la liste est vide.
// =========================================================================
// src/app/ex17-routeur/pages/a-propos.page.ts - une page chargée à la demande
// -------------------------------------------------------------------------
// Cf. loadComponent dans routes.ts : le code de cette page est placé, par
// la compilation (ng build), dans un fichier JavaScript à part.
// =========================================================================

import { Component } from '@angular/core';
import { IonContent } from '@ionic/angular';

@Component({
  selector: 'app-ex17-a-propos',
  imports: [IonContent],
  templateUrl: './a-propos.page.html',
})
export class APropos {}
<!-- ======================================================================
     a-propos.page.html - l'onglet À propos de l'exemple 17
     ====================================================================== -->

<ion-content class="ion-padding">
  <p>
    Sur le web, le code de cette page a été téléchargé à la première visite (onglet Réseau des
    outils de développement : un fichier chunk-XXXXXXXX.js). Sur un téléphone, ce fichier est
    déjà dans l'application : il est lu sur place, sans téléchargement.
  </p>
</ion-content>

La page chargée à la demande (cf. [loadComponent] dans [routes.ts]) n'a rien de particulier : c'est la route qui décide du chargement différé. Le résultat de [ng build] montre son fichier à part (« Lazy chunk files ... a-propos-page »). Dans l'application des exemples, d'ailleurs, toutes les pages sont chargées ainsi (cf. [app.routes.ts]) : au démarrage, on ne télécharge que le menu.

// =========================================================================
// src/app/ex17-routeur/pages/chargement-differe.ts
// -------------------------------------------------------------------------
// Une barre de progression, affichée tant qu'une navigation est EN COURS :
// pendant ce temps, le routeur télécharge le code d'une page différée
// (loadComponent), ou attend la réponse d'un garde (exemple 18).
// currentNavigation est un SIGNAL du routeur : la navigation en cours, ou
// null quand il n'y en a pas.
// =========================================================================

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { IonProgressBar } from '@ionic/angular';

@Component({
  selector: 'app-chargement-differe',
  imports: [IonProgressBar],
  templateUrl: './chargement-differe.html',
})
export class ChargementDiffere {
  readonly router = inject(Router);
}
1
2
3
4
5
6
7
<!-- ======================================================================
     chargement-differe.html - une barre de progression "indéterminée"
     ====================================================================== -->

@if (router.currentNavigation()) {
  <ion-progress-bar type="indeterminate" />
}

Avec [Flutter], le widget [ChargementDiffere] affichait un indicateur de chargement en attendant le code de la page. Avec [Angular], le routeur attend lui-même le code avant d'afficher la page : l'ancienne page reste à l'écran pendant ce temps. Le composant [ChargementDiffere] affiche donc une barre de progression tant qu'une navigation est en cours :

  • ligne 5 (html) : [router.currentNavigation()] est un signal du routeur : la navigation en cours, ou [null]. Pendant le téléchargement d'une page différée (ou l'attente d'un garde, exemple 18), la barre de progression ([<ion-progress-bar type="indeterminate">], une barre animée sans fin) est affichée ; à la fin de la navigation, le signal repasse à [null], et elle disparaît. La coquille la place sous sa barre du haut (ligne 17 de coquille.html).
// =========================================================================
// src/app/ex17-routeur/pages/page-introuvable.page.ts - les adresses inconnues
// -------------------------------------------------------------------------
// La page de la route joker '**' (routes.ts) : elle est HORS de la
// coquille, elle a donc sa propre barre du haut.
// =========================================================================

import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { IonButton, IonContent, IonHeader, IonText, IonTitle, IonToolbar, NavController } from '@ionic/angular';

@Component({
  selector: 'app-ex17-page-introuvable',
  imports: [IonHeader, IonToolbar, IonTitle, IonContent, IonText, IonButton],
  templateUrl: './page-introuvable.page.html',
})
export class PageIntrouvable {
  private readonly router = inject(Router);
  private readonly navCtrl = inject(NavController);

  // l'adresse demandée (cf. coquille.ts)
  readonly adresse = computed(() => {
    const navigation = this.router.lastSuccessfulNavigation();
    return navigation?.finalUrl ? decodeURIComponent(this.router.serializeUrl(navigation.finalUrl)) : '';
  });

  retourAccueil(): void {
    // navigateBack : une navigation "en arrière" (l'animation va de gauche
    // à droite) ; la page introuvable est dépilée
    this.navCtrl.navigateBack('/ex17/accueil');
  }
}
<!-- ======================================================================
     page-introuvable.page.html - la page des adresses inconnues (exemple 17)
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Page introuvable</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <p><ion-text color="danger">Aucune page à l'adresse {{ adresse() }}.</ion-text></p>
  <ion-button (click)="retourAccueil()">Retour à l'accueil</ion-button>
</ion-content>

La page des adresses inconnues est hors de la coquille : elle a sa propre barre du haut (lignes 5-9 du html). Elle affiche l'adresse demandée (lignes 22-25 du .ts, comme dans la coquille), et son bouton ramène à l'accueil par une navigation « en arrière » (ligne 30 du .ts) : la page introuvable est dépilée, et la coquille, restée en mémoire en dessous, réapparaît.

5.1.6. L'exécution

L'accueil ; le détail d'un médecin (empilé au-dessus de la liste : la barre d'onglets est toujours là, et le bouton Retour du téléphone ramène à la liste) ; la recherche, après avoir tapé « généraliste » et appuyé sur Entrée ; une adresse inconnue (le lien cassé de l'accueil) :

Dans un navigateur ([ng serve]), l'adresse s'affiche aussi dans la barre d'adresse, les boutons Précédent et Suivant fonctionnent, et l'on peut taper une adresse à la main : /ex17/medecins/3, /ex17/recherche?q=pédiatre, /ex17/docteurs...

Comparé à [go_router], le routeur d'[Angular] est plus déclaratif : les routes sont une simple liste d'objets, les paramètres arrivent dans les entrées de la page, et le chargement différé n'est qu'une propriété de la route. [Ionic] y ajoute ce qui est propre au mobile : la pile de pages ([<ion-router-outlet>]), les onglets à piles séparées ([<ion-tabs>]), le sens des navigations et leurs animations ([NavController]). On retrouvera exactement les mêmes notions dans le client [Angular] de [RdvMedecins] (les routes, [routerLink], [router.navigate]) : seuls les composants d'[Ionic] sont propres au mobile.

5.2. Exemple 18 : les gardes de navigation

Certaines pages ne doivent être ouvertes que par un utilisateur connecté, ou par un utilisateur d'un certain rôle. Le routeur d'[Angular] les protège par des gardes : des fonctions qu'il appelle avant d'y aller.

5.2.1. La session

// =========================================================================
// src/app/ex18-gardes/session.ts - une session SIMULÉE (sans serveur)
// -------------------------------------------------------------------------
// L'utilisateur connecté doit être connu des PAGES (la barre du haut,
// l'agenda) et des GARDES du routeur (des fonctions, qui ne sont pas des
// composants). On le range dans un SERVICE, un objet unique que chacun
// obtient par inject(Session) (cf. exemple 14) :
//   - un signal privé, l'état ;
//   - une vue en lecture seule de ce signal ;
//   - des méthodes pour le modifier.
// Ceux qui lisent le signal (les gabarits) sont prévenus quand il change.
// (L'exemple 23 fera la même chose avec un vrai serveur.)
// =========================================================================

import { Injectable, signal } from '@angular/core';

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

export interface Utilisateur {
  login: string;
  role: Role;
}

// trois comptes "en dur" (mot de passe = login)
const comptes: Record<string, Role> = { admin: 'ADMIN', pelissier: 'DOCTOR', martin: 'USER' };

// providedIn: 'root' : UN seul exemplaire, pour toute l'application
@Injectable({ providedIn: 'root' })
export class Session {
  // l'état : null, personne n'est connecté
  private readonly _utilisateur = signal<Utilisateur | null>(null);
  // asReadonly : on peut lire le signal, pas le modifier
  readonly utilisateur = this._utilisateur.asReadonly();

  connecter(login: string, password: string): boolean {
    const role = comptes[login];
    if (role === undefined || password !== login) {
      return false;
    }
    this._utilisateur.set({ login, role });
    return true;
  }

  deconnecter(): void {
    this._utilisateur.set(null);
  }
}

L'utilisateur connecté doit être connu des pages (la barre du haut, l'agenda) et des gardes du routeur, des fonctions qui ne sont pas des composants. On le range dans un service (cf. exemple 14), un objet unique que chacun obtient par [inject(Session)] :

  • lignes 17-22 : les rôles (un type « union » de trois chaînes) et l'utilisateur ;
  • ligne 25 : trois comptes « en dur » (mot de passe = login) : un objet qui associe un login à un rôle ([Record<string, Role>]) ;
  • ligne 28 : [providedIn: 'root'] : un seul exemplaire du service, pour toute l'application ;
  • lignes 31-33 : l'état, un signal privé ([null] : personne n'est connecté), et sa vue en lecture seule ([asReadonly()]) : les autres peuvent le lire, mais seules les méthodes du service le modifient ;
  • lignes 35-42 : la connexion, simulée. Elle modifie le signal : tout ce qui le lit (les gabarits, les signaux calculés) suit. C'est le [notifyListeners()] du [ChangeNotifier] de [Flutter], sans avoir à l'écrire. (L'exemple 23 fera la même chose avec un vrai serveur.)

5.2.2. Les gardes et les routes protégées

// =========================================================================
// src/app/ex18-gardes/routes.ts - des routes PROTÉGÉES par des gardes
// -------------------------------------------------------------------------
// canActivate : la liste des GARDES d'une route, des fonctions (de type
// CanActivateFn) que le routeur appelle AVANT d'y aller. Un garde reçoit
// la route demandée et l'état de la navigation (state.url : l'adresse
// demandée), et renvoie :
//   - true : la navigation continue ;
//   - false : elle est annulée ;
//   - un UrlTree (une adresse fabriquée par le routeur) : la navigation
//     est remplacée par une navigation vers cette adresse.
// Un garde peut utiliser inject() : il est appelé dans un "contexte
// d'injection".
//
// C'est l'équivalent, côté client, des gardes NestJS (JwtAuthGuard,
// RolesGuard) - avec une différence FONDAMENTALE : un garde du client n'est
// qu'un confort. L'application est entre les mains de l'utilisateur, qui
// peut la modifier à volonté. La vraie protection des données reste celle
// du SERVEUR (cf. chapitre sur la boîte noire).
// =========================================================================

import { inject } from '@angular/core';
import { CanActivateFn, Router, Routes } from '@angular/router';
import { Coquille } from './coquille';
import { Accueil } from './pages/accueil.page';
import { Agenda } from './pages/agenda.page';
import { Connexion } from './pages/connexion.page';
import { Role, Session } from './session';

// le garde "connexion requise" : pas connecté -> à la page de connexion, en
// retenant où l'on voulait aller (paramètre returnUrl)
export const connexionRequise: CanActivateFn = (route, state) => {
  if (inject(Session).utilisateur() === null) {
    return inject(Router).createUrlTree(['/ex18/connexion'], { queryParams: { returnUrl: state.url } });
  }
  return true;
};

// le garde "rôle requis" : une fonction qui FABRIQUE un garde
export function roleRequis(...roles: Role[]): CanActivateFn {
  return (route, state) => {
    const u = inject(Session).utilisateur();
    if (u === null || !roles.includes(u.role)) {
      // connecté, mais pas avec le bon rôle : retour à l'accueil, avec un motif
      return inject(Router).createUrlTree(['/ex18'], { queryParams: { refus: state.url } });
    }
    return true;
  };
}

export const routes: Routes = [
  {
    path: '',
    component: Coquille,
    children: [
      { path: '', pathMatch: 'full', component: Accueil },
      { path: 'connexion', component: Connexion },
      { path: 'agenda', canActivate: [connexionRequise], component: Agenda },
      {
        path: 'administration',
        // les gardes, dans l'ordre : le premier qui ne renvoie pas true l'emporte
        canActivate: [connexionRequise, roleRequis('ADMIN')],
        // le code de la page n'est chargé qu'une fois les gardes franchis
        loadComponent: () => import('./pages/administration.page').then((m) => m.Administration),
      },
      // toute autre adresse (aucune route ne correspond) : retour à l'accueil
      { path: '**', redirectTo: '' },
    ],
  },
];

Un garde d'activation est une fonction de type [CanActivateFn], placée dans la liste [canActivate] d'une route. Le routeur l'appelle avant d'aller à cette route ; elle reçoit la route demandée et l'état de la navigation ([state.url] : l'adresse demandée), et rend :

  • [true] : la navigation continue ;
  • [false] : la navigation est annulée ;
  • un [UrlTree], une adresse fabriquée par le routeur ([router.createUrlTree]) : la navigation est remplacée par une navigation vers cette adresse.

Un garde peut aussi rendre une [Promise] ou un [Observable] de l'une de ces valeurs (pour interroger un serveur, par exemple). Le code :

  • lignes 32-37 : le garde « connexion requise » : pas connecté ? à la page de connexion, en retenant où l'on voulait aller (paramètre de requête [returnUrl]). Le garde utilise [inject()] : le routeur l'appelle dans un « contexte d'injection » ;
  • lignes 40-49 : le garde « rôle requis » est FABRIQUÉ par une fonction, qui reçoit les rôles permis ([...roles] : autant de paramètres qu'on veut, réunis dans un tableau) et rend le garde. Connecté, mais sans le bon rôle : retour à l'accueil, avec un motif (paramètre [refus]) ;
  • lignes 52-55 : la coquille et ses routes enfants, comme dans l'exemple 17 ;
  • ligne 58 : l'agenda exige d'être connecté ;
  • lignes 59-65 : l'administration exige d'être connecté ET d'avoir le rôle ADMIN. Les gardes de la liste sont appelés dans l'ordre ; le premier qui ne rend pas [true] l'emporte (c'était l'enchaînement par [??] avec [go_router]). Le code de la page n'est chargé ([loadComponent]) qu'une fois les gardes franchis ;
  • ligne 67 : toute autre adresse sous /ex18 ramène à l'accueil.

C'est l'équivalent, côté client, des gardes du serveur [NestJS] ([JwtAuthGuard], [RolesGuard]), avec une différence FONDAMENTALE : un garde du client n'est qu'un confort (ne pas afficher un écran inutile). L'application est entre les mains de l'utilisateur, qui peut la modifier à volonté ; la vraie protection des données reste celle du serveur, qui revérifie tout à chaque requête (cf. le chapitre sur la boîte noire).

Une différence avec [go_router] : son option [refreshListenable] réévaluait les gardes de la page courante à chaque changement de la session (on était renvoyé à la connexion si l'on se déconnectait sur l'agenda). Le routeur d'[Angular] n'appelle les gardes qu'au moment d'une navigation. C'est pourquoi la déconnexion (plus loin) est suivie d'une navigation vers l'accueil.

5.2.3. La coquille et les pages

// =========================================================================
// src/app/ex18-gardes/coquille.ts - les liens, et l'utilisateur connecté
// -------------------------------------------------------------------------
// La page de l'exemple 18 : la barre du haut (l'utilisateur connecté), une
// barre de liens, et, dessous, la page de la route enfant courante, placée
// par <router-outlet>. Le gabarit LIT le signal de la session : il suit
// donc l'utilisateur connecté.
// =========================================================================

import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import {
  IonBackButton, IonButton, IonButtons, IonChip, IonContent, IonHeader, IonIcon, IonTitle, IonToolbar,
} from '@ionic/angular';
import { addIcons } from 'ionicons';
import { logOutOutline } from 'ionicons/icons';
import { ChargementDiffere } from '../ex17-routeur/pages/chargement-differe';
import { Session } from './session';

@Component({
  selector: 'app-ex18-coquille',
  imports: [
    RouterOutlet, RouterLink, RouterLinkActive, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle,
    IonButton, IonChip, IonIcon, IonContent, ChargementDiffere,
  ],
  templateUrl: './coquille.html',
  styles: `
    .liens ion-button { text-transform: none; }
    .liens .actif { font-weight: bold; }
  `,
})
export class Coquille {
  readonly session = inject(Session);
  private readonly router = inject(Router);

  constructor() {
    addIcons({ logOutOutline });
  }

  deconnecter(): void {
    this.session.deconnecter();
    this.router.navigateByUrl('/ex18');
  }
}
<!-- ======================================================================
     coquille.html - la coquille de l'exemple 18
     ====================================================================== -->

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/" />
    </ion-buttons>
    <ion-title>Exemple 18 - les gardes</ion-title>
    <ion-buttons slot="end">
      <!-- @if (...; as u) : l'utilisateur connecté, nommé u dans le bloc -->
      @if (session.utilisateur(); as u) {
        <ion-chip outline color="light">{{ u.login }} {{ u.role }}</ion-chip>
        <ion-button aria-label="déconnexion" (click)="deconnecter()">
          <ion-icon slot="icon-only" name="log-out-outline" />
        </ion-button>
      } @else {
        <ion-button routerLink="/ex18/connexion">connexion</ion-button>
      }
    </ion-buttons>
  </ion-toolbar>
  <!-- les liens ; routerLinkActive : la classe CSS "actif" est posée sur le
       lien qui désigne la page affichée ; exact : l'adresse entière doit
       correspondre (sinon /ex18 serait "actif" sur toutes les pages) -->
  <ion-toolbar class="liens">
    <ion-button fill="clear" size="small" routerLink="/ex18" routerLinkActive="actif"
      [routerLinkActiveOptions]="{ exact: true }">Accueil</ion-button>
    <ion-button fill="clear" size="small" routerLink="/ex18/agenda" routerLinkActive="actif">
      Agenda (connecté)</ion-button>
    <ion-button fill="clear" size="small" routerLink="/ex18/administration" routerLinkActive="actif">
      Administration (ADMIN)</ion-button>
  </ion-toolbar>
  <app-chargement-differe />
</ion-header>

<ion-content class="ion-padding">
  <!-- la page de la route enfant courante -->
  <router-outlet />
</ion-content>
  • ligne 17 (.ts) : la coquille réutilise la barre de progression de l'exemple 17 ;
  • lignes 40-43 (.ts) : la déconnexion, puis l'accueil ;
  • lignes 11-21 (html) : la barre du haut lit le signal de la session : elle suit l'utilisateur connecté (c'était [ListenableBuilder] avec [Flutter]). Connecté, un [<ion-chip>] (une « puce ») affiche son login et son rôle, à côté d'un bouton de déconnexion ; sinon, un lien vers la page de connexion ;
  • lignes 26-33 (html) : une seconde barre, avec les liens. La directive [routerLinkActive="actif"] pose la classe CSS [actif] (en gras, ligne 29 du .ts) sur le lien qui désigne la page affichée. Pour l'accueil, [[routerLinkActiveOptions]="{ exact: true }"] exige que l'adresse entière corresponde : sans cela, /ex18 étant le début de toutes les adresses de l'exemple, ce lien serait toujours « actif » ;
  • ligne 39 (html) : [<router-outlet>], celui d'[Angular] : la page de la route enfant courante. Ces pages ne sont que des morceaux du contenu de la coquille, sans barre du haut ni animation : le simple [<router-outlet>] d'[Angular] suffit, dans le [<ion-content>] de la coquille. Le [<ion-router-outlet>] d'[Ionic] est réservé aux pages complètes, qu'on empile.
// =========================================================================
// src/app/ex18-gardes/pages/connexion.page.ts - la page de connexion
// -------------------------------------------------------------------------
// Après la connexion, on renvoie l'utilisateur à la page qu'il voulait
// ouvrir (paramètre ?returnUrl=... déposé par le garde), ou à l'accueil.
// =========================================================================

import { Component, inject, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { IonButton, IonCard, IonCardContent, IonInput, IonText } from '@ionic/angular';
import { Session } from '../session';

@Component({
  selector: 'app-ex18-connexion',
  imports: [FormsModule, IonCard, IonCardContent, IonInput, IonButton, IonText],
  templateUrl: './connexion.page.html',
})
export class Connexion {
  // l'adresse demandée, déposée par le garde connexionRequise
  readonly returnUrl = input<string>();

  // les champs du formulaire et l'erreur : des signaux
  readonly login = signal('');
  readonly password = signal('');
  readonly erreur = signal(false);

  private readonly session = inject(Session);
  private readonly router = inject(Router);

  valider(): void {
    if (!this.session.connecter(this.login().trim(), this.password())) {
      this.erreur.set(true);
      return;
    }
    // on n'accepte qu'un chemin INTERNE : il commence par /, mais pas par //
    // (//site.com/... désigne un AUTRE site) - pas de redirection vers un autre site
    const r = this.returnUrl();
    const interne = r !== undefined && r.startsWith('/') && !r.startsWith('//');
    this.router.navigateByUrl(interne ? r : '/ex18');
  }
}
<!-- ======================================================================
     connexion.page.html - le formulaire de connexion (exemple 18)
     ====================================================================== -->

@if (returnUrl()) {
  <ion-card color="warning">
    <ion-card-content>Connectez-vous pour ouvrir {{ returnUrl() }}.</ion-card-content>
  </ion-card>
}
<!-- [(ngModel)]="login" : le champ et le signal login restent égaux -->
<ion-input label="login" labelPlacement="floating" fill="outline" class="ion-margin-bottom" [(ngModel)]="login" />
<ion-input label="mot de passe" labelPlacement="floating" fill="outline" type="password"
  [(ngModel)]="password" (keyup.enter)="valider()" />
<ion-button class="ion-margin-top" (click)="valider()">Se connecter</ion-button>
@if (erreur()) {
  <p><ion-text color="danger">Login ou mot de passe incorrect.</ion-text></p>
}
  • ligne 21 (.ts) : l'adresse demandée, déposée par le garde dans le paramètre [returnUrl] ;
  • lignes 24-26 (.ts) : les champs du formulaire et l'erreur : des signaux ;
  • lignes 31-41 (.ts) : la connexion. En cas d'échec, l'erreur ; sinon, on renvoie l'utilisateur à la page qu'il voulait ouvrir, ou à l'accueil. On n'accepte qu'un chemin interne : il commence par [/], mais pas par [//] ([//site.com/...] désigne un AUTRE site). Sur le web, une adresse de connexion fabriquée par un tiers ([/ex18/connexion?returnUrl=//site-pirate.com]) ne mènera donc jamais ailleurs. [router.navigateByUrl] accepte une adresse complète, avec ses paramètres de requête ;
  • lignes 5-9 (html) : le motif de la connexion ;
  • lignes 11-13 (html) : [[(ngModel)]="login"] lie le champ au signal [login] dans les deux sens (cf. exemple 05) ; [(keyup.enter)] valide le formulaire par la touche Entrée.

Les autres pages sont très simples :

// =========================================================================
// src/app/ex18-gardes/pages/accueil.page.ts - la page publique
// -------------------------------------------------------------------------
// ?refus=... : déposé dans l'adresse par le garde roleRequis quand le rôle
// ne convient pas ; le routeur le donne à l'entrée refus.
// =========================================================================

import { Component, input } from '@angular/core';
import { IonCard, IonCardContent } from '@ionic/angular';

@Component({
  selector: 'app-ex18-accueil',
  imports: [IonCard, IonCardContent],
  templateUrl: './accueil.page.html',
})
export class Accueil {
  readonly refus = input<string>();
}
<!-- ======================================================================
     accueil.page.html - la page publique de l'exemple 18
     ====================================================================== -->

@if (refus()) {
  <ion-card color="danger">
    <ion-card-content>Votre rôle ne permet pas d'ouvrir {{ refus() }}.</ion-card-content>
  </ion-card>
}
<p>Page publique. Essayez les deux autres liens, connecté ou non (admin, pelissier, martin - mot de passe = login).</p>
// =========================================================================
// src/app/ex18-gardes/pages/agenda.page.ts - une page réservée aux connectés
// =========================================================================

import { Component, inject } from '@angular/core';
import { Session } from '../session';

@Component({
  selector: 'app-ex18-agenda',
  templateUrl: './agenda.page.html',
})
export class Agenda {
  readonly session = inject(Session);
}
1
2
3
4
5
<!-- ======================================================================
     agenda.page.html - la page de l'agenda (exemple 18)
     ====================================================================== -->

<p>L'agenda de <strong>{{ session.utilisateur()?.login }}</strong> : cette page exige d'être connecté.</p>
// =========================================================================
// src/app/ex18-gardes/pages/administration.page.ts - réservée au rôle ADMIN
// -------------------------------------------------------------------------
// Chargée à la demande (loadComponent, dans routes.ts) : son code n'est
// téléchargé qu'à la première visite, et seulement si les gardes l'ont permis.
// =========================================================================

import { Component } from '@angular/core';

@Component({
  selector: 'app-ex18-administration',
  templateUrl: './administration.page.html',
})
export class Administration {}
1
2
3
4
5
6
7
8
<!-- ======================================================================
     administration.page.html - la page d'administration (exemple 18)
     ====================================================================== -->

<p>
  Administration : page réservée au rôle ADMIN, chargée à la demande (sur le web, son code n'est
  téléchargé qu'à la première visite, et seulement si les gardes l'ont permis).
</p>

L'accueil reçoit le paramètre [refus] déposé par le garde [roleRequis] (ligne 17 du .ts) ; l'agenda lit la session.

5.2.4. L'exécution

Sans être connecté, on demande l'administration : le garde mène à la page de connexion (à gauche). On se connecte avec le compte [martin] (rôle USER) : la connexion ramène à l'administration, dont le second garde refuse l'accès (au milieu). On se déconnecte, on redemande l'administration, et l'on se connecte avec le compte [admin] : la page s'ouvre (à droite) :

Les gardes d'[Angular] existent aussi sous d'autres formes : [canMatch] (la route n'est même pas reconnue si le garde refuse : son code n'est pas téléchargé), [canDeactivate] (quitter la page : « Abandonner les modifications ? »), [canActivateChild] (pour toutes les routes enfants d'une route). Le client [Angular] de [RdvMedecins] les utilise de la même façon : un garde du client ne fait que guider l'utilisateur ; c'est le serveur qui décide.