Skip to content

4. La vue et son modèle

Jusqu'ici, les actions fabriquaient elles-mêmes leur réponse. Dans une application MVC, c'est le rôle d'une vue : un fichier qui décrit la page HTML, avec des emplacements pour les données que lui transmet l'action, le modèle de la vue. Nous utiliserons le moteur de vues [Handlebars], via le paquet [hbs] qui l'adapte à [Express].

4.1. Exemple 13 : une première vue

13-premiere-vue/
├── src/
│   ├── app.module.ts
│   ├── main.ts
│   └── vues.controller.ts
├── views/
│   ├── inconnu.hbs
│   └── index.hbs
├── nest-cli.json
└── tsconfig.json

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
  // NestExpressApplication : donne accès aux méthodes propres à Express
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // les vues sont dans le dossier [views], à la racine du projet ;
  // une fois compilé, ce fichier est dans dist/ : la racine est donc en '..'
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  // le moteur de vues : Handlebars, fourni par le paquet [hbs]
  app.setViewEngine('hbs');
  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();

Commentons ce code :

  • ligne 2 : [NestExpressApplication] est le type d'une application [NestJS] qui s'appuie sur [Express] ([NestJS] peut aussi utiliser le serveur [Fastify]). Le préciser donne accès aux méthodes propres à [Express] : [setBaseViewsDir], [setViewEngine], [useStaticAssets] ;
  • ligne 8 : le type est passé à [NestFactory.create] ;
  • ligne 11 : le dossier des vues. [__dirname] est le dossier du fichier en cours d'exécution : [dist], puisque c'est [dist/main.js] qui s'exécute. Le dossier [views] est donc en [dist/../views] ;
  • ligne 13 : le moteur de vues : [hbs]. Les vues sont les fichiers [.hbs] du dossier des vues.

Le fichier [src/vues.controller.ts] :

import { Controller, Get, Param, Render, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller()
export class VuesController {
  // @Render('index') : l'objet rendu par l'action est le MODÈLE de la vue
  // views/index.hbs ; NestJS appelle lui-même le moteur de vues
  @Get()
  @Render('index')
  accueil(): object {
    return {
      titre: 'Ma première vue',
      message: 'Cette page a été fabriquée par Handlebars, sur le serveur.',
      heure: new Date().toLocaleTimeString('fr-FR'),
    };
  }

  // res.render(vue, modèle) : la vue est choisie PENDANT l'exécution de l'action
  @Get('bonjour/:nom')
  bonjour(@Param('nom') nom: string, @Res() res: Response): void {
    if (nom === 'inconnu') {
      res.status(404).render('inconnu', { nom });
      return;
    }
    res.render('index', {
      titre: `Bonjour ${nom}`,
      message: 'Cette fois, la vue a été choisie par res.render.',
      heure: new Date().toLocaleTimeString('fr-FR'),
    });
  }
}
  • lignes 8-9 : [@Render('index')] désigne la vue [views/index.hbs]. L'objet rendu par l'action n'est plus la réponse : c'est le modèle de la vue ;
  • lignes 11-15 : le modèle : trois valeurs, [titre], [message] et [heure] ;
  • lignes 19-30 : quand la vue dépend de la requête, l'action utilise [res.render(vue, modèle)] de la réponse [Express] (injectée par [@Res()], cf. exemple 05). Ici, le nom [inconnu] affiche une autre vue, avec le code 404.

Le fichier [views/index.hbs] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>{{titre}}</title>
</head>
<body>
  <h1>{{titre}}</h1>
  <p>{{message}}</p>
  <p>Page fabriquée à {{heure}}.</p>
</body>
</html>

Une vue [Handlebars] est un fichier HTML. Chaque expression [{{titre}}] (lignes 5, 8) est remplacée par la valeur du champ correspondant du modèle. Le résultat :

Image

Le code source de la page reçue par le navigateur ne contient plus aucune trace de [Handlebars] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Ma première vue</title>
</head>
<body>
  <h1>Ma première vue</h1>
  <p>Cette page a été fabriquée par Handlebars, sur le serveur.</p>
  <p>Page fabriquée à 16:10:29.</p>
</body>
</html>

L'URL [/bonjour/martin] affiche la même vue avec un autre modèle :

Image

et [/bonjour/inconnu] affiche une autre vue :

curl -i http://localhost:3000/bonjour/inconnu
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
Content-Length: 168

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [inconnu] ici</h1>
</body>
</html>

4.2. Exemple 14 : le langage Handlebars

[Handlebars] est un langage de modèles sans logique : une vue ne contient pas de code [JavaScript], seulement des expressions [{{...}}] et quelques blocs ([{{#if}}], [{{#each}}]...). Tout calcul est fait avant, par l'action. L'exemple 14 en fait le tour. Le modèle :

Le fichier [src/bases.controller.ts] :

import { Controller, Get, Render } from '@nestjs/common';

@Controller()
export class BasesController {
  @Get()
  @Render('bases')
  bases(): object {
    return {
      titre: 'Les bases de Handlebars',
      nom: 'Martin',
      html: '<b>ceci est du HTML</b>',
      connecte: true,
      devise: '€',
      produits: [
        { nom: 'Clavier', prix: 25, enStock: true },
        { nom: 'Souris', prix: 12.5, enStock: false },
        { nom: 'Écran', prix: 149, enStock: true },
      ],
      panier: [],
      adresse: { rue: '12 rue des Lilas', ville: 'Angers' },
    };
  }
}

La vue :

Le fichier [views/bases.hbs] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>{{titre}}</title>
</head>
<body>
  <h1>{{titre}}</h1>
  {{!-- 1. afficher une valeur du modèle --}}
  <p>1. Nom : {{nom}} - variable absente : [{{inexistante}}]</p>
  {{!-- 2. {{ }} échappe le HTML, {{{ }}} l'insère tel quel --}}
  <p>2a. {{html}}</p>
  <p>2b. {{{html}}}</p>
  {{!-- 3. une condition --}}
  {{#if connecte}}
    <p>3. Vous êtes connecté.</p>
  {{else}}
    <p>3. Vous n'êtes pas connecté.</p>
  {{/if}}
  {{#unless connecte}}
    <p>Ce paragraphe n'est pas affiché.</p>
  {{/unless}}
  {{!-- 4. une boucle ; dans la boucle, le contexte est l'élément courant --}}
  <p>4. Nos produits :</p>
  <ul>
    {{#each produits}}
      <li>n° {{@index}} : {{nom}}, {{prix}} {{../devise}}
        {{#if enStock}}(en stock){{else}}(épuisé){{/if}}
        {{#if @first}}- le premier{{/if}}
        {{#if @last}}- le dernier, vu par {{@root.nom}}{{/if}}
      </li>
    {{/each}}
  </ul>
  {{!-- 5. {{else}} dans une boucle : le cas de la liste vide --}}
  <p>5. Votre panier :
    {{#each panier}}{{nom}}{{else}}il est vide.{{/each}}
  </p>
  {{!-- 6. changer de contexte, ou utiliser un chemin pointé --}}
  {{#with adresse}}
    <p>6a. Adresse : {{rue}}, {{ville}}</p>
  {{/with}}
  <p>6b. Ville : {{adresse.ville}}</p>
</body>
</html>

Commentons ce code :

  • ligne 9 : [{{!-- ... --}}] est un commentaire [Handlebars] : il n'apparaît pas dans la page produite (contrairement à un commentaire HTML [<!-- -->]) ;
  • ligne 10 : une variable absente du modèle ([inexistante]) produit une chaîne vide, sans erreur ;
  • lignes 12-13 : [{{html}}] échappe les caractères spéciaux du HTML ([<] devient [&lt;]...) : le texte s'affiche tel quel. [{{{{{{html}}}}}}] (trois accolades) l'insère sans échappement : il est interprété comme du HTML. On n'utilise les triples accolades que pour du HTML fabriqué par l'application, jamais pour une donnée saisie par un utilisateur : celui-ci pourrait injecter du HTML, voire du [JavaScript], dans la page (attaque dite [XSS]) ;
  • lignes 15-19 : [{{#if}}] / [{{else}}] / [{{/if}}] : un bloc conditionnel. La condition est une valeur du modèle, pas une expression : [false], [undefined], [null], [''], [0] et le tableau vide sont faux ;
  • lignes 20-22 : [{{#unless}}] est l'inverse de [{{#if}}] ;
  • lignes 26-32 : [{{#each}}] répète son contenu pour chaque élément d'un tableau. À l'intérieur du bloc, le contexte est l'élément courant : [{{{{nom}}}}] est le nom du produit courant. Des variables spéciales sont disponibles : [@index] (position à partir de 0), [@first], [@last]. [../devise] remonte au contexte parent, [@root.nom] au modèle racine ;
  • ligne 36 : un [{{else}}] dans un [{{#each}}] traite le cas du tableau vide ;
  • lignes 39-41 : [{{#with}}] change de contexte ; on peut aussi utiliser un chemin pointé (ligne 42).

Image

Le code HTML produit :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Les bases de Handlebars</title>
</head>
<body>
  <h1>Les bases de Handlebars</h1>
  <p>1. Nom : Martin - variable absente : []</p>
  <p>2a. &lt;b&gt;ceci est du HTML&lt;/b&gt;</p>
  <p>2b. <b>ceci est du HTML</b></p>
    <p>3. Vous êtes connecté.</p>
  <p>4. Nos produits :</p>
  <ul>
      <li>n° 0 : Clavier, 25 €
        (en stock)
        - le premier

      </li>
      <li>n° 1 : Souris, 12.5 €
        (épuisé)


      </li>
      <li>n° 2 : Écran, 149 €
        (en stock)

        - le dernier, vu par Martin
      </li>
  </ul>
  <p>5. Votre panier :
    il est vide.
  </p>
    <p>6a. Adresse : 12 rue des Lilas, Angers</p>
  <p>6b. Ville : Angers</p>
</body>
</html>

On remarque les lignes blanches laissées par les blocs : elles sont sans effet sur l'affichage.

4.3. Exemple 15 : gabarit, partiels, fichiers statiques

Les pages d'un site partagent un gabarit commun (entête, barre de navigation, pied de page). [hbs] le gère ainsi : si le dossier des vues contient un fichier [layout.hbs], toute vue est insérée dans ce gabarit. Les morceaux de page réutilisables sont des partiels.

15-layout-partiels/
├── public/
│   └── css/
│       └── styles.css
├── src/
│   ├── app.module.ts
│   ├── main.ts
│   └── pages.controller.ts
├── views/
│   ├── partials/
│   │   ├── carte.hbs
│   │   └── navbar.hbs
│   ├── accueil.hbs
│   ├── brute.hbs
│   ├── equipe.hbs
│   └── layout.hbs
├── nest-cli.json
└── tsconfig.json

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import * as hbs from 'hbs';
import { dirname, join } from 'path';
import { AppModule } from './app.module';

const RACINE = join(__dirname, '..');

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 1. fichiers statiques : public/css/styles.css est servi en /css/styles.css
  app.useStaticAssets(join(RACINE, 'public'));
  // Bootstrap est servi directement depuis node_modules, sous /bootstrap/
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });

  // 2. les vues ; views/layout.hbs sera le gabarit de TOUTES les pages
  app.setBaseViewsDir(join(RACINE, 'views'));
  app.setViewEngine('hbs');
  // 3. les partiels : views/partials/xxx.hbs est inséré par {{> xxx}}
  await new Promise<void>((fini) => hbs.registerPartials(join(RACINE, 'views', 'partials'), () => fini()));

  // 4. un middleware Express : ce qu'il dépose dans res.locals est
  //    ajouté au modèle de CHAQUE vue
  app.use((req: Request, res: Response, next: NextFunction) => {
    res.locals.chemin = req.path;
    res.locals.annee = new Date().getFullYear();
    next();
  });

  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();

Commentons ce code :

  • ligne 14 : [useStaticAssets] sert les fichiers statiques d'un dossier : le fichier [public/css/styles.css] est servi à l'URL [/css/styles.css], tel quel, sans passer par un contrôleur ;
  • ligne 16 : le framework CSS [Bootstrap] est servi directement depuis le dossier [node_modules], sous le préfixe [/bootstrap/]. [require.resolve] trouve le dossier du paquet [bootstrap], où qu'il soit installé (ici dans [exemples/node_modules]) ;
  • ligne 22 : les fichiers du dossier [views/partials] deviennent des partiels : [partials/navbar.hbs] s'insère dans une vue par [{{> navbar}}]. [registerPartials] lit le dossier de façon asynchrone : on attend qu'il ait fini (la fonction passée en second argument est appelée à la fin) avant de démarrer le serveur ;
  • lignes 26-30 : un middleware [Express], exécuté avant chaque requête. Ce qu'il range dans [res.locals] est ajouté au modèle de chaque vue. C'est le moyen de fournir à toutes les pages des informations communes (ici le chemin demandé et l'année) sans les répéter dans chaque action.

Le fichier [views/layout.hbs] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{titre}} - Mon site</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3">
    {{> navbar}}
    {{!-- la vue demandée par l'action est insérée ici --}}
    {{{body}}}
    <footer class="pied">Page [{{chemin}}] - © {{annee}}</footer>
  </div>
</body>
</html>
  • lignes 7-8 : les feuilles de style, servies par [useStaticAssets] ;
  • ligne 12 : insertion du partiel [navbar] ;
  • ligne 14 : [{{{body}}}] est l'emplacement où [hbs] insère la vue demandée par l'action. Triples accolades : la vue est du HTML déjà fabriqué, qu'il ne faut pas échapper ;
  • ligne 15 : [chemin] et [annee] viennent de [res.locals] (le middleware de [main.ts]).

Le fichier [views/partials/navbar.hbs] :

1
2
3
4
5
6
7
8
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Mon site</span>
  <div class="navbar-nav">
    <a class="nav-link" href="/">Accueil</a>
    <a class="nav-link" href="/equipe">Équipe</a>
    <a class="nav-link" href="/brute">Page sans gabarit</a>
  </div>
</nav>

Le fichier [views/partials/carte.hbs] :

1
2
3
4
5
6
7
{{!-- une "carte" Bootstrap ; paramètres : nom, fonction --}}
<div class="card">
  <div class="card-body">
    <h5 class="card-title">{{nom}}</h5>
    <p class="card-text">{{fonction}}</p>
  </div>
</div>

Le partiel [carte] affiche un nom et une fonction. Il est utilisé de deux façons :

Le fichier [views/accueil.hbs] :

1
2
3
4
<h1>Bienvenue</h1>
<p>Cette page ne contient que son propre contenu : l'entête, la barre de
  navigation et le pied de page viennent du gabarit <code>layout.hbs</code>.</p>
{{> carte nom="Un partiel" fonction="inséré avec des paramètres nommés"}}

ligne 4 : avec des paramètres nommés : le partiel les reçoit comme contexte ;

Le fichier [views/equipe.hbs] :

1
2
3
4
5
6
7
8
9
<h1>{{titre}}</h1>
<div class="row g-3">
  {{#each membres}}
    <div class="col-md-4">
      {{!-- sans paramètre, le partiel reçoit le contexte courant : le membre --}}
      {{> carte}}
    </div>
  {{/each}}
</div>

ligne 6 : sans paramètre : le partiel reçoit le contexte courant, ici le membre de l'équipe en cours dans la boucle [{{#each}}].

Le fichier [src/pages.controller.ts] :

import { Controller, Get, Render } from '@nestjs/common';

@Controller()
export class PagesController {
  @Get()
  @Render('accueil')
  accueil(): object {
    return { titre: 'Accueil' };
  }

  @Get('equipe')
  @Render('equipe')
  equipe(): object {
    return {
      titre: 'Notre équipe',
      membres: [
        { nom: 'Marie Pélissier', fonction: 'Directrice' },
        { nom: 'Jacques Bromard', fonction: 'Développeur' },
        { nom: 'Philippe Jandot', fonction: 'Graphiste' },
      ],
    };
  }

  // layout: false : la vue est rendue SEULE, sans le gabarit commun
  @Get('brute')
  @Render('brute')
  brute(): object {
    return { layout: false };
  }
}

lignes 25-29 : le champ [layout: false] du modèle demande à [hbs] de ne pas utiliser le gabarit : la vue [brute.hbs] est une page HTML complète. On pourrait aussi désigner un autre gabarit ([layout: 'autre']). Les pages obtenues :

Image

Image

Image

Le gabarit, les partiels et [res.locals] sont exactement ce qu'utilise l'étude de cas : une vue [layout.hbs], des partiels pour la barre de navigation, les messages, les fenêtres de confirmation, et un middleware qui prépare la langue et le message à afficher.

4.4. Exemple 16 : les helpers

Un helper est une fonction [TypeScript] appelable depuis une vue. Il comble l'absence de logique de [Handlebars] : comparer deux valeurs, formater une date ou un prix, traduire un texte... Les blocs [{{#if}}] et [{{#each}}] sont eux-mêmes des helpers prédéfinis.

Le fichier [src/helpers.ts] :

import { HelperOptions } from 'handlebars';

// le module 'hbs' n'a pas de déclarations de types : on décrit ce qu'on en utilise
interface Hbs {
  registerHelper(nom: string, fonction: (...args: any[]) => unknown): void;
  handlebars: { SafeString: new (html: string) => unknown; escapeExpression(texte: string): string };
}

export function enregistrerHelpers(hbs: Hbs): void {
  // {{majuscules nom}} : un helper simple, un argument
  hbs.registerHelper('majuscules', (texte: unknown) => String(texte ?? '').toUpperCase());

  // {{#if (eq a b)}} : utilisé comme sous-expression, entre parenthèses
  hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);

  // {{actif chemin '/produits'}} : "active" si la page courante est sous ce chemin
  hbs.registerHelper('actif', (chemin: unknown, prefixe: unknown) =>
    typeof chemin === 'string' && typeof prefixe === 'string' && chemin.startsWith(prefixe) ? 'active' : '',
  );

  // {{monnaie montant devise='USD'}} : le DERNIER argument d'un helper est toujours
  // l'objet [options] de Handlebars ; options.hash contient les paramètres
  // nommés, options.data.root le modèle complet de la page (res.locals compris)
  hbs.registerHelper('monnaie', (montant: unknown, options: HelperOptions) => {
    const devise = options.hash.devise ?? 'EUR';
    const langue = options.data?.root?.lang ?? 'fr-FR';
    return new Intl.NumberFormat(langue, { style: 'currency', currency: devise }).format(Number(montant));
  });

  // {{date quand}} : une date au format de la langue de la page
  hbs.registerHelper('date', (quand: unknown, options: HelperOptions) => {
    const langue = options.data?.root?.lang ?? 'fr-FR';
    return new Intl.DateTimeFormat(langue, { dateStyle: 'full' }).format(new Date(String(quand)));
  });

  // {{badge stock}} : un helper qui fabrique du HTML. SafeString indique à
  // Handlebars de ne PAS l'échapper ; c'est alors au helper d'échapper
  // lui-même les données qu'il y insère (escapeExpression)
  hbs.registerHelper('badge', (stock: unknown) => {
    const n = Number(stock);
    const classe = n > 0 ? 'text-bg-success' : 'text-bg-danger';
    const texte = hbs.handlebars.escapeExpression(n > 0 ? `${n} en stock` : 'épuisé');
    return new hbs.handlebars.SafeString(`<span class="badge ${classe}">${texte}</span>`);
  });
}

Commentons ce code :

  • lignes 4-7 : le paquet [hbs] n'a pas de déclarations de types [TypeScript] : on décrit ce qu'on en utilise ;
  • ligne 11 : [hbs.registerHelper(nom, fonction)] enregistre un helper. Dans une vue, [{{majuscules nom}}] appelle la fonction avec la valeur de [nom] ;
  • ligne 14 : [eq] rend un booléen. Il s'utilise en sous-expression, entre parenthèses, dans un autre helper : [{{#if (eq chemin '/')}}] ;
  • lignes 17-19 : [actif] rend la classe CSS [active] si le chemin de la page commence par un préfixe donné ;
  • lignes 24-28 : le dernier argument d'un helper est toujours un objet fourni par [Handlebars], [options]. [options.hash] contient les paramètres nommés ([{{monnaie prix devise='USD'}}]), et [options.data.root] le modèle racine de la page, auquel [res.locals] a été ajouté. Le helper y trouve la langue de la page, même s'il est appelé à l'intérieur d'un [{{#each}}]. Le formatage est confié à l'objet standard [Intl.NumberFormat] ;
  • lignes 31-34 : même principe pour une date, avec [Intl.DateTimeFormat] ;
  • lignes 39-44 : un helper qui fabrique du HTML. Par défaut, ce que rend un helper est échappé, comme toute expression [{{...}}]. Pour l'éviter, on le rend sous forme de [SafeString]. Le helper doit alors échapper lui-même les données qu'il insère dans son HTML ([escapeExpression]).

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import * as hbs from 'hbs';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { enregistrerHelpers } from './helpers';

const RACINE = join(__dirname, '..');

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(RACINE, 'public'));
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
  app.setBaseViewsDir(join(RACINE, 'views'));
  app.setViewEngine('hbs');
  // les helpers : des fonctions TypeScript appelables depuis les vues
  enregistrerHelpers(hbs);
  await new Promise<void>((fini) => hbs.registerPartials(join(RACINE, 'views', 'partials'), () => fini()));
  app.use((req: Request, res: Response, next: NextFunction) => {
    res.locals.chemin = req.path;
    res.locals.lang = req.query.lang === 'en' ? 'en-US' : 'fr-FR';
    next();
  });
  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();
  • ligne 18 : les helpers sont enregistrés au démarrage ;
  • ligne 22 : le middleware choisit la langue de formatage selon le paramètre [lang] de l'URL (une façon simple de tester ; le chapitre suivant fera mieux).

Le fichier [views/partials/navbar.hbs] :

1
2
3
4
5
6
7
8
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Helpers</span>
  <div class="navbar-nav">
    {{!-- le lien de la page courante reçoit la classe "active" --}}
    <a class="nav-link {{#if (eq chemin '/')}}active{{/if}}" href="/">Accueil</a>
    <a class="nav-link {{actif chemin '/produits'}}" href="/produits">Produits</a>
  </div>
</nav>

lignes 5-6 : les deux façons de mettre en évidence le lien de la page courante : [eq] en sous-expression dans un [{{#if}}], ou le helper [actif], plus concis. L'étude de cas utilise [actif].

Le fichier [views/produits.hbs] :

<h1>{{titre}}</h1>
<table class="table table-sm w-auto">
  <thead><tr><th>Produit</th><th>Prix</th><th>Prix en dollars</th><th>Stock</th></tr></thead>
  <tbody>
    {{#each produits}}
      <tr>
        <td>{{majuscules nom}}</td>
        <td>{{monnaie prix}}</td>
        <td>{{monnaie prix devise='USD'}}</td>
        <td>{{badge stock}}</td>
      </tr>
    {{/each}}
  </tbody>
</table>

Les pages obtenues, en français puis en anglais ([?lang=en]) :

Image

Image

Image

4.5. Exemple 17 : un formulaire GET

Un formulaire HTML envoyé par la méthode GET place ses champs dans l'URL. C'est la méthode qui convient à une recherche : elle ne modifie rien, et l'URL obtenue peut être mise en favori ou partagée. L'étude de cas l'utilise pour choisir un médecin et un jour.

Le fichier [src/livres.controller.ts] :

import { Controller, Get, Query, Render } from '@nestjs/common';

interface Livre {
  titre: string;
  auteur: string;
  genre: string;
}

const LIVRES: Livre[] = [
  { titre: 'Le Rouge et le Noir', auteur: 'Stendhal', genre: 'roman' },
  { titre: 'Les Fleurs du mal', auteur: 'Baudelaire', genre: 'poésie' },
  { titre: 'Madame Bovary', auteur: 'Flaubert', genre: 'roman' },
  { titre: 'Alcools', auteur: 'Apollinaire', genre: 'poésie' },
  { titre: 'Le Cid', auteur: 'Corneille', genre: 'théâtre' },
];
const GENRES = ['roman', 'poésie', 'théâtre'];

@Controller()
export class LivresController {
  // GET /recherche?motCle=...&genre=... : le formulaire ET ses résultats
  @Get(['/', 'recherche'])
  @Render('recherche')
  recherche(@Query('motCle') motCle = '', @Query('genre') genre = ''): object {
    const mot = motCle.trim().toLowerCase();
    const resultats = LIVRES.filter(
      (l) =>
        (mot === '' || l.titre.toLowerCase().includes(mot) || l.auteur.toLowerCase().includes(mot)) &&
        (genre === '' || l.genre === genre),
    );
    return { motCle, genre, genres: GENRES, resultats, nombre: resultats.length };
  }
}
  • lignes 9-15 : les livres, en mémoire ;
  • ligne 21 : un tableau de chemins : l'action traite [GET /] et [GET /recherche] ;
  • ligne 23 : une valeur par défaut [TypeScript] remplace un paramètre absent (à la première visite, le formulaire n'a pas encore été envoyé) ;
  • lignes 24-29 : le filtrage ;
  • ligne 30 : le modèle contient les valeurs saisies ([motCle], [genre]), pour que le formulaire les réaffiche, la liste des genres, et les résultats.

Le fichier [views/recherche.hbs] :

<h1 class="h3">Recherche de livres</h1>
{{!-- method="get" : les champs sont envoyés dans l'URL (?motCle=...&genre=...) --}}
<form method="get" action="/recherche" class="row g-2 align-items-end mb-3">
  <div class="col-auto">
    <label class="form-label" for="motCle">Titre ou auteur</label>
    {{!-- value="{{motCle}}" : le champ réaffiche ce qui a été saisi --}}
    <input class="form-control" id="motCle" name="motCle" value="{{motCle}}">
  </div>
  <div class="col-auto">
    <label class="form-label" for="genre">Genre</label>
    <select class="form-select" id="genre" name="genre">
      <option value="">(tous)</option>
      {{#each genres}}
        <option value="{{this}}" {{#if (eq this ../genre)}}selected{{/if}}>{{this}}</option>
      {{/each}}
    </select>
  </div>
  <div class="col-auto">
    <button class="btn btn-primary" type="submit">Rechercher</button>
  </div>
</form>
<p>{{nombre}} livre(s) trouvé(s).</p>
<ul>
  {{#each resultats}}
    <li><i>{{titre}}</i>, {{auteur}} ({{genre}})</li>
  {{/each}}
</ul>
  • ligne 3 : [method="get"] et [action="/recherche"] : l'envoi du formulaire est une requête [GET /recherche?motCle=...&genre=...]. Le nom des paramètres est l'attribut [name] de chaque champ ;
  • ligne 7 : l'attribut [value] réaffiche la saisie ;
  • lignes 13-15 : dans la liste déroulante, l'option égale au genre choisi reçoit l'attribut [selected]. Dans la boucle, [this] est l'élément courant (une chaîne) et [../genre] le genre du modèle parent.

Au premier affichage, puis après une recherche :

Image

Image

La barre d'adresse montre les paramètres envoyés. Les caractères accentués et les espaces y sont codés ([%C3%A9]...) : c'est l'encodage URL, que [NestJS] décode avant de remplir [@Query].

4.6. Exemple 18 : les champs d’un formulaire

L'exemple 18 réunit tous les types de champs HTML : texte, mot de passe, date, boutons radio, cases à cocher, liste déroulante, liste à sélection multiple, zone de texte, champ caché. Il montre comment le serveur les reçoit et comment la vue les réaffiche.

Le fichier [src/listes.service.ts] :

import { Injectable } from '@nestjs/common';

// les listes proposées par le formulaire. Dans une vraie application, elles
// viendraient d'une base de données : on les lirait UNE fois, au démarrage,
// puisqu'elles sont les mêmes pour tous les utilisateurs
@Injectable()
export class ListesService {
  readonly titres = ['Mr', 'Mme', 'Melle'];
  readonly pays = [
    { code: 'fr', nom: 'France' },
    { code: 'be', nom: 'Belgique' },
    { code: 'ch', nom: 'Suisse' },
    { code: 'ca', nom: 'Canada' },
  ];
  readonly loisirs = ['lecture', 'musique', 'sport', 'cinéma'];
  readonly langues = ['français', 'anglais', 'espagnol', 'allemand'];
}

Les listes proposées par le formulaire sont fournies par un service. Elles sont les mêmes pour tous les utilisateurs : un service, instancié une seule fois (portée application, cf. chapitre 6), est l'endroit naturel pour les garder.

Le fichier [src/formulaire.controller.ts] :

import { Body, Controller, Get, HttpCode, Post, Render } from '@nestjs/common';
import { ListesService } from './listes.service';

// ce que l'utilisateur a saisi, une fois remis en forme
interface Saisie {
  nom: string;
  motDePasse: string;
  commentaire: string;
  titre: string;
  loisirs: string[];
  pays: string;
  langues: string[];
  naissance: string;
  jeton: string;
}

@Controller('formulaire')
export class FormulaireController {
  constructor(private readonly listes: ListesService) {}

  // GET : le formulaire, avec des valeurs initiales
  @Get()
  @Render('formulaire')
  afficher(): object {
    const initial: Saisie = {
      nom: '', motDePasse: '', commentaire: '', titre: 'Mme', loisirs: [],
      pays: 'fr', langues: ['français'], naissance: '', jeton: 'A7X2',
    };
    return this.modele(initial, null);
  }

  // POST : on reçoit les valeurs saisies et on réaffiche le formulaire
  @Post()
  @HttpCode(200) // un POST répond 201 par défaut ; ici on renvoie une page
  @Render('formulaire')
  traiter(@Body() corps: Record<string, string | string[]>): object {
    const saisie: Saisie = {
      nom: texte(corps.nom),
      motDePasse: texte(corps.motDePasse),
      commentaire: texte(corps.commentaire),
      titre: texte(corps.titre),
      loisirs: tableau(corps.loisirs),
      pays: texte(corps.pays),
      langues: tableau(corps.langues),
      naissance: texte(corps.naissance),
      jeton: texte(corps.jeton),
    };
    return this.modele(saisie, corps);
  }

  // le modèle de la vue : les listes, chaque option étant marquée
  // cochée / sélectionnée selon la saisie
  private modele(saisie: Saisie, recu: object | null): object {
    return {
      saisie,
      titres: this.listes.titres.map((t) => ({ valeur: t, coche: t === saisie.titre })),
      loisirs: this.listes.loisirs.map((l) => ({ valeur: l, coche: saisie.loisirs.includes(l) })),
      pays: this.listes.pays.map((p) => ({ ...p, selectionne: p.code === saisie.pays })),
      langues: this.listes.langues.map((l) => ({ valeur: l, selectionne: saisie.langues.includes(l) })),
      recu: recu ? JSON.stringify(recu, null, 2) : null,
    };
  }
}

// un champ absent (case non cochée...) n'est pas envoyé du tout
function texte(valeur: unknown): string {
  return typeof valeur === 'string' ? valeur : '';
}

// une valeur cochée : "sport" ; deux : ["sport", "musique"] ; aucune : undefined
function tableau(valeur: unknown): string[] {
  if (valeur === undefined) return [];
  return Array.isArray(valeur) ? valeur : [String(valeur)];
}

Commentons ce code :

  • lignes 5-15 : la saisie, remise en forme : les cases à cocher et la liste multiple sont des tableaux ;
  • ligne 19 : le service des listes est injecté ;
  • lignes 22-30 : [GET /formulaire] affiche le formulaire avec des valeurs initiales ;
  • ligne 34 : [POST /formulaire] rend une page, pas une ressource créée : on remplace le code 201 par 200 ;
  • lignes 37-47 : les champs reçus sont remis en forme. Deux cas demandent de l'attention : un champ absent (une case non cochée n'est pas envoyée du tout, cf. fonction [texte], lignes 66-68), et un champ multiple, qui arrive sous forme de chaîne s'il n'a qu'une valeur (fonction [tableau], lignes 71-74, cf. exemple 10) ;
  • lignes 53-62 : le modèle de la vue. Pour chaque liste, l'action calcule quelles options doivent être cochées ou sélectionnées : la vue n'a plus qu'à tester un booléen. Le champ [recu] contient ce que le serveur a reçu, pour l'afficher.

Le fichier [views/formulaire.hbs] :

<h1 class="h3">Un formulaire complet</h1>
<form method="post" action="/formulaire" style="max-width: 40rem">
  {{!-- champ caché : envoyé avec le formulaire, mais invisible --}}
  <input type="hidden" name="jeton" value="{{saisie.jeton}}">

  <div class="mb-2">
    <span class="form-label me-3">Titre</span>
    {{#each titres}}
      {{!-- boutons radio : même [name], une seule valeur envoyée --}}
      <div class="form-check form-check-inline">
        <input class="form-check-input" type="radio" name="titre" id="titre-{{valeur}}"
               value="{{valeur}}" {{#if coche}}checked{{/if}}>
        <label class="form-check-label" for="titre-{{valeur}}">{{valeur}}</label>
      </div>
    {{/each}}
  </div>

  <div class="row g-2 mb-2">
    <div class="col">
      <label class="form-label" for="nom">Nom</label>
      <input class="form-control" type="text" id="nom" name="nom" value="{{saisie.nom}}">
    </div>
    <div class="col">
      <label class="form-label" for="motDePasse">Mot de passe</label>
      {{!-- un mot de passe n'est JAMAIS renvoyé dans la page --}}
      <input class="form-control" type="password" id="motDePasse" name="motDePasse">
    </div>
    <div class="col">
      <label class="form-label" for="naissance">Date de naissance</label>
      <input class="form-control" type="date" id="naissance" name="naissance" value="{{saisie.naissance}}">
    </div>
  </div>

  <div class="mb-2">
    <span class="form-label me-3">Loisirs</span>
    {{#each loisirs}}
      {{!-- cases à cocher : même [name], plusieurs valeurs possibles --}}
      <div class="form-check form-check-inline">
        <input class="form-check-input" type="checkbox" name="loisirs" id="loisir-{{@index}}"
               value="{{valeur}}" {{#if coche}}checked{{/if}}>
        <label class="form-check-label" for="loisir-{{@index}}">{{valeur}}</label>
      </div>
    {{/each}}
  </div>

  <div class="row g-2 mb-2">
    <div class="col">
      <label class="form-label" for="pays">Pays</label>
      <select class="form-select" id="pays" name="pays">
        {{#each pays}}
          <option value="{{code}}" {{#if selectionne}}selected{{/if}}>{{nom}}</option>
        {{/each}}
      </select>
    </div>
    <div class="col">
      <label class="form-label" for="langues">Langues parlées</label>
      {{!-- liste à sélection multiple : plusieurs valeurs possibles --}}
      <select class="form-select" id="langues" name="langues" multiple size="4">
        {{#each langues}}
          <option value="{{valeur}}" {{#if selectionne}}selected{{/if}}>{{valeur}}</option>
        {{/each}}
      </select>
    </div>
  </div>

  <div class="mb-2">
    <label class="form-label" for="commentaire">Commentaire</label>
    {{!-- une zone de texte : la valeur est ENTRE les balises --}}
    <textarea class="form-control" id="commentaire" name="commentaire" rows="2">{{saisie.commentaire}}</textarea>
  </div>

  <button class="btn btn-primary" type="submit">Envoyer</button>
</form>

{{#if recu}}
  <h2 class="h5 mt-3">Ce que le serveur a reçu</h2>
  <pre class="bg-light p-2 border">{{recu}}</pre>
{{/if}}

Commentons ce code :

  • ligne 2 : le formulaire est envoyé par POST : ses champs voyagent dans le corps de la requête ;
  • ligne 4 : un champ caché est envoyé avec le formulaire mais n'est pas affiché. Il sert à transporter une information d'une page à la suivante (l'étude de cas y met, par exemple, la version d'une fiche). Attention : l'utilisateur peut le modifier ;
  • lignes 8-15 : des boutons radio portent le même [name] : un seul peut être coché, et une seule valeur est envoyée. L'attribut [checked] coche le bouton ;
  • ligne 26 : un mot de passe n'est jamais renvoyé dans une page : le champ n'a pas d'attribut [value] ;
  • ligne 30 : un champ date. Le navigateur affiche la date dans le format de l'utilisateur, mais envoie toujours [aaaa-mm-jj] ;
  • lignes 36-43 : des cases à cocher de même [name] : chaque case cochée envoie sa valeur ; le paramètre est donc répété ;
  • lignes 49-53 : une liste déroulante. L'option choisie porte l'attribut [selected] ;
  • lignes 58-62 : l'attribut [multiple] permet de choisir plusieurs options ;
  • ligne 69 : la valeur d'une zone de texte est entre ses balises, pas dans un attribut [value] ;
  • lignes 75-78 : ce que le serveur a reçu, affiché dans une balise [<pre>].

Le formulaire initial, puis après envoi :

Image

Image

On remarque que la zone de texte a réaffiché [Bonjour <b>tout</b> le monde] tel quel : grâce à l'échappement de [{{...}}], les balises saisies ne sont pas interprétées. Le mot de passe a été reçu mais n'est pas réaffiché. Avec [curl], on voit la différence entre une case cochée :

curl -d "jeton=A7X2&titre=Mr&nom=Martin&loisirs=sport" http://localhost:3000/formulaire
1
2
3
4
5
6
{
  "jeton": "A7X2",
  "titre": "Mr",
  "nom": "Martin",
  "loisirs": "sport"
}

et deux cases cochées :

curl -d "jeton=A7X2&titre=Mr&nom=Martin&loisirs=sport&loisirs=musique" http://localhost:3000/formulaire
1
2
3
4
5
6
7
8
9
{
  "jeton": "A7X2",
  "titre": "Mr",
  "nom": "Martin",
  "loisirs": [
    "sport",
    "musique"
  ]
}

(seule la partie [Ce que le serveur a reçu] de la page est reproduite)

4.7. Exemple 19 : la validation d’un formulaire

Un formulaire invalide doit être réaffiché, avec les valeurs saisies et un message sous chaque champ erroné. L'exemple 19 met en œuvre la technique de l'exemple 09 (validation faite par l'action) sur un formulaire d'inscription.

Le fichier [src/inscription.dto.ts] :

import { Transform, Type } from 'class-transformer';
import { IsEmail, IsInt, IsNotEmpty, Max, MaxLength, Min, MinLength } from 'class-validator';

// retire les espaces superflus d'une saisie AVANT sa validation
const nettoyer = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);

export class InscriptionDto {
  @Transform(nettoyer)
  @IsNotEmpty({ message: 'Le nom est obligatoire.' })
  @MaxLength(20, { message: 'Le nom ne doit pas dépasser 20 caractères.' })
  nom: string;

  @Transform(nettoyer)
  @IsEmail({}, { message: 'Adresse électronique invalide.' })
  email: string;

  // tout ce qui vient d'un formulaire est une chaîne : "42" -> 42
  @Type(() => Number)
  @IsInt({ message: "L'âge doit être un nombre entier." })
  @Min(18, { message: 'Il faut avoir au moins 18 ans.' })
  @Max(120, { message: "L'âge ne peut dépasser 120 ans." })
  age: number;

  @MinLength(6, { message: 'Le mot de passe doit comporter au moins 6 caractères.' })
  motDePasse: string;

  // "confirmation = mot de passe" porte sur DEUX champs : c'est le
  // contrôleur qui le vérifie
  confirmation: string;
}
  • ligne 5 : une fonction de nettoyage, appliquée par [@Transform] avant la validation : un nom fait d'espaces devient une chaîne vide, et [@IsNotEmpty] le refuse ;
  • lignes 18-22 : l'âge est converti en nombre, puis validé ;
  • lignes 27-29 : la règle « la confirmation est égale au mot de passe » porte sur deux champs : les décorateurs ne la voient pas, le contrôleur la vérifiera.

Le fichier [src/validation.ts] :

import { ClassConstructor, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

// erreurs par champ : { nom: 'Le nom est obligatoire.', ... }
export type Erreurs = Record<string, string>;

export async function validerFormulaire<T extends object>(
  classe: ClassConstructor<T>,
  corps: Record<string, unknown>,
): Promise<{ dto: T; erreurs: Erreurs }> {
  // 1. objet reçu -> instance de la classe (les @Transform / @Type s'appliquent)
  const dto = plainToInstance(classe, corps);
  // 2. vérification de toutes les contraintes
  const violations = await validate(dto);
  // 3. on garde le premier message de chaque champ en erreur
  const erreurs: Erreurs = {};
  for (const v of violations) {
    erreurs[v.property] = Object.values(v.constraints ?? {})[0];
  }
  return { dto, erreurs };
}
  • ligne 5 : les erreurs sont rangées par champ : [{ nom: 'Le nom est obligatoire.' }], ce qui permet d'afficher chaque message sous son champ ;
  • lignes 7-10 : la fonction est générique : elle valide n'importe quelle classe de formulaire. Elle rend l'instance convertie ([dto]) et les erreurs ;
  • lignes 17-19 : un champ peut violer plusieurs contraintes : on ne garde que le premier message.

L'étude de cas a la même fonction ([web/helpers/formulaire.helper.ts]).

Le fichier [src/inscription.controller.ts] :

import { Body, Controller, Get, Post, Query, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { InscriptionDto } from './inscription.dto';
import { validerFormulaire } from './validation';

@Controller('inscription')
export class InscriptionController {
  // ?html5=1 : on laisse le navigateur valider le formulaire avant l'envoi
  @Get()
  @Render('inscription')
  afficher(@Query('html5') html5?: string): object {
    return { valeurs: {}, erreurs: {}, html5: html5 === '1' };
  }

  @Post()
  async inscrire(
    @Body() corps: Record<string, string>,
    @Query('html5') html5: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    const { dto, erreurs } = await validerFormulaire(InscriptionDto, corps);
    // une règle qui porte sur deux champs
    if (!erreurs.motDePasse && corps.confirmation !== corps.motDePasse) {
      erreurs.confirmation = 'La confirmation ne correspond pas au mot de passe.';
    }
    if (Object.keys(erreurs).length > 0) {
      // on RÉAFFICHE le formulaire : valeurs saisies (sauf les mots de passe)
      // et un message sous chaque champ erroné
      res.render('inscription', {
        valeurs: { nom: corps.nom, email: corps.email, age: corps.age },
        erreurs,
        erreurGlobale: 'Le formulaire contient des erreurs.',
        html5: html5 === '1',
      });
      return;
    }
    res.render('bienvenue', { nom: dto.nom, email: dto.email, age: dto.age });
  }
}
  • lignes 9-13 : le formulaire vide. Le paramètre [html5] sera expliqué plus loin ;
  • ligne 21 : validation de chaque champ ;
  • lignes 23-25 : puis de la règle qui porte sur deux champs ;
  • lignes 26-36 : s'il y a des erreurs, on réaffiche le formulaire : les valeurs saisies (sauf les mots de passe), les erreurs par champ, et un message global ;
  • ligne 37 : sinon, la page de bienvenue, avec les valeurs converties du [dto] (l'âge est un nombre, le nom a été nettoyé).

Les champs sont tous construits sur le même modèle : un partiel les factorise.

Le fichier [views/partials/champ.hbs] :

1
2
3
4
5
6
7
8
{{!-- un champ de saisie ; paramètres : nom, libelle, type, valeur, erreur, et
      attributs = les attributs de validation HTML5 (required, maxlength...) --}}
<div class="mb-2">
  <label class="form-label" for="{{nom}}">{{libelle}}</label>
  <input class="form-control {{#if erreur}}is-invalid{{/if}}" id="{{nom}}" name="{{nom}}"
         type="{{type}}" value="{{valeur}}" {{{attributs}}}>
  {{#if erreur}}<div class="invalid-feedback">{{erreur}}</div>{{/if}}
</div>
  • ligne 5 : la classe [Bootstrap] [is-invalid] encadre en rouge un champ erroné ;
  • ligne 6 : les attributs de validation HTML5 sont passés en paramètre et insérés sans échappement (triples accolades) : ils sont écrits par le développeur, pas saisis par l'utilisateur ;
  • ligne 7 : le message d'erreur, sous le champ (classe [invalid-feedback]).

Le fichier [views/inscription.hbs] :

<h1 class="h3">Inscription</h1>
{{#if html5}}
  <p>Validation par le navigateur <b>activée</b> - <a href="/inscription">la désactiver</a></p>
{{else}}
  <p>Validation par le navigateur <b>désactivée</b> - <a href="/inscription?html5=1">l'activer</a></p>
{{/if}}
{{!-- novalidate : le navigateur ignore les attributs required, min... et
      envoie le formulaire tel quel ; seul le serveur valide --}}
<form method="post" action="/inscription{{#if html5}}?html5=1{{/if}}" {{#unless html5}}novalidate{{/unless}}>
  {{> champ nom="nom" libelle="Nom" type="text" valeur=valeurs.nom erreur=erreurs.nom
            attributs='required maxlength="20"'}}
  {{> champ nom="email" libelle="Adresse électronique" type="email" valeur=valeurs.email
            erreur=erreurs.email attributs="required"}}
  {{> champ nom="age" libelle="Âge" type="number" valeur=valeurs.age erreur=erreurs.age
            attributs='required min="18" max="120"'}}
  {{> champ nom="motDePasse" libelle="Mot de passe" type="password" erreur=erreurs.motDePasse
            attributs='required minlength="6"'}}
  {{> champ nom="confirmation" libelle="Confirmation" type="password" erreur=erreurs.confirmation
            attributs="required"}}
  <button class="btn btn-primary" type="submit">S'inscrire</button>
</form>
  • ligne 9 : l'attribut [novalidate] est présent quand [html5] est faux ;
  • lignes 10-19 : chaque champ est un appel du partiel, avec des paramètres nommés. Un paramètre peut être une valeur du modèle ([valeur=valeurs.nom]).

Le fichier [views/layout.hbs] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inscription</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    {{{body}}}
    {{!-- l'erreur globale, en bas de page, sur fond rose léger --}}
    {{#if erreurGlobale}}
      <div class="zone-erreur" role="alert">{{erreurGlobale}}</div>
    {{/if}}
  </div>
</body>
</html>

lignes 13-15 : le message global est affiché en bas de la page, sur un fond rose léger (classe [zone-erreur] de [public/css/styles.css]), comme dans l'étude de cas.

Envoyons le formulaire avec plusieurs erreurs :

Image

puis correctement rempli :

Image

4.7.1. La validation par le navigateur

Les attributs [required], [maxlength], [min], [max], [type="email"]... demandent au navigateur de vérifier le formulaire avant de l'envoyer : l'utilisateur est prévenu immédiatement, par une bulle d'aide, sans aller-retour avec le serveur. C'est le comportement obtenu avec le lien « l'activer » ([/inscription?html5=1]), qui retire l'attribut [novalidate] du formulaire.

Cette validation est un confort, pas une protection : il suffit de retirer l'attribut [required] avec les outils de développement du navigateur, ou d'envoyer la requête avec [curl], pour la contourner. La validation par le serveur reste indispensable. C'est pourquoi l'exemple désactive par défaut la validation du navigateur : pour voir travailler le serveur. L'étude de cas, elle, garde les deux : les attributs HTML5 pour le confort, et la validation serveur pour la sûreté.

4.8. Exemple 20 : le schéma Post / Redirect / Get

Quand un formulaire POST réussit, l'action peut afficher directement une page. Le problème apparaît si l'utilisateur recharge ensuite cette page (touche F5) : le navigateur renvoie le formulaire, et l'opération est faite une seconde fois (une commande passée deux fois, un rendez-vous pris deux fois...). Le schéma Post / Redirect / Get (PRG) évite ce problème : une action POST réussie ne fabrique pas de page, elle redirige vers une page GET. Recharger cette page ne renvoie que la requête GET, sans effet.

Reste un détail : la page GET doit afficher un message (« la tâche a été ajoutée »), alors que ce message a été produit par l'action POST, dans une autre requête. On le transmet dans un cookie de courte durée, lu et effacé par la requête suivante : c'est un message flash.

Le fichier [src/flash.ts] :

import { NextFunction, Request, Response } from 'express';

const COOKIE_FLASH = 'flash';

// dépose un message qui sera affiché par la PROCHAINE page, et une seule fois
export function flash(res: Response, message: string): void {
  res.cookie(COOKIE_FLASH, message, { httpOnly: true, sameSite: 'lax', maxAge: 60_000 });
}

// à chaque requête : le message éventuel passe du cookie à res.locals (donc
// au modèle de la vue), et le cookie est effacé
export function flashMiddleware(req: Request, res: Response, next: NextFunction): void {
  res.locals.flash = req.cookies?.[COOKIE_FLASH] ?? null;
  if (res.locals.flash) {
    res.clearCookie(COOKIE_FLASH);
  }
  next();
}
  • lignes 6-8 : [res.cookie(nom, valeur, options)] dépose un cookie : le navigateur le renverra à chaque requête suivante. Options : [httpOnly] (le [JavaScript] de la page ne peut pas le lire), [sameSite: 'lax'] (il n'est pas envoyé par les requêtes POST venues d'autres sites), [maxAge] (durée de vie : une minute, largement de quoi suivre la redirection) ;
  • lignes 12-18 : un middleware exécuté avant chaque requête : le message éventuel passe du cookie à [res.locals], donc au modèle de la vue, et le cookie est effacé ([res.clearCookie]) : le message ne s'affichera qu'une fois.

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { flashMiddleware } from './flash';

const RACINE = join(__dirname, '..');

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(RACINE, 'public'));
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
  app.setBaseViewsDir(join(RACINE, 'views'));
  app.setViewEngine('hbs');
  // cookie-parser remplit req.cookies avec les cookies envoyés par le navigateur
  app.use(cookieParser());
  // puis notre middleware lit (et efface) le message flash éventuel
  app.use(flashMiddleware);
  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();
  • ligne 17 : le middleware [cookie-parser] décode l'entête [Cookie] de la requête et remplit l'objet [req.cookies] ;
  • ligne 19 : notre middleware, qui a besoin de [req.cookies], est enregistré après lui : les middlewares s'exécutent dans l'ordre de leur enregistrement.

Le fichier [src/taches.controller.ts] :

import { Body, Controller, Get, Post, Res } from '@nestjs/common';
import { Response } from 'express';
import { flash } from './flash';
import { TachesService } from './taches.service';

@Controller('taches')
export class TachesController {
  constructor(private readonly taches: TachesService) {}

  // GET /taches : la liste et le formulaire d'ajout
  @Get()
  afficher(@Res() res: Response): void {
    res.render('taches', { taches: this.taches.liste() });
  }

  // POST /taches : le schéma Post / Redirect / Get
  @Post()
  ajouter(@Body('libelle') libelle: string | undefined, @Res() res: Response): void {
    const texte = (libelle ?? '').trim();
    if (texte === '' || texte.length > 40) {
      // saisie invalide : on réaffiche la page, sans redirection
      res.render('taches', {
        taches: this.taches.liste(),
        libelle,
        erreur: 'Le libellé doit comporter de 1 à 40 caractères.',
      });
      return;
    }
    this.taches.ajouter(texte);
    flash(res, `La tâche [${texte}] a été ajoutée.`);
    // pas de page : une redirection vers la page GET
    res.redirect('/taches');
  }

  // POST /taches/sans-prg : le contre-exemple (une page en réponse au POST)
  @Post('sans-prg')
  ajouterSansPrg(@Body('libelle') libelle: string | undefined, @Res() res: Response): void {
    this.taches.ajouter((libelle ?? '').trim() || '(sans titre)');
    res.render('taches', { taches: this.taches.liste(), sansPrg: true });
  }
}
  • lignes 11-14 : la page de la liste ;
  • lignes 20-27 : une saisie invalide : on réaffiche la page (pas de redirection : il faut garder la saisie et le message d'erreur) ;
  • lignes 29-32 : une saisie valide : l'opération, le message flash, puis la redirection (302) vers la page GET ;
  • lignes 36-40 : le contre-exemple, pour comparer.

Le fichier [views/taches.hbs] :

<h1 class="h3">Mes tâches</h1>
<ol>
  {{#each taches}}<li>{{this}}</li>{{/each}}
</ol>
<form method="post" action="/taches" class="d-flex gap-2">
  <input class="form-control" name="libelle" value="{{libelle}}" placeholder="Nouvelle tâche">
  <button class="btn btn-primary text-nowrap" type="submit">Ajouter</button>
  {{!-- formaction : ce bouton envoie le MÊME formulaire à une autre adresse --}}
  <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="/taches/sans-prg">Ajouter sans PRG</button>
</form>
{{#if sansPrg}}
  <p class="text-danger mt-2">Page affichée en réponse à un POST : appuyez sur F5 et la tâche sera ajoutée une seconde fois.</p>
{{/if}}

ligne 9 : l'attribut [formaction] d'un bouton envoie le formulaire à une autre adresse que celle de l'attribut [action] du formulaire. Le formulaire a ainsi deux boutons, qui déclenchent deux actions différentes. L'étude de cas utilise ce procédé sur sa page de connexion.

Le fichier [views/layout.hbs] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Tâches</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    {{!-- le message flash, en haut de page --}}
    {{#if flash}}
      <div class="alert alert-success" role="status">{{flash}}</div>
    {{/if}}
    {{{body}}}
    {{#if erreur}}
      <div class="zone-erreur" role="alert">{{erreur}}</div>
    {{/if}}
  </div>
</body>
</html>

Le message flash est affiché en haut de la page (lignes 12-14), l'erreur en bas (lignes 16-18).

Voici ce que voit [curl] pour l'envoi réussi du formulaire. L'option [-c fichier] enregistre les cookies reçus, [-b fichier] les renvoie :

curl -i -c cookies.txt -b cookies.txt -d "libelle=Réviser NestJS" http://localhost:3000/taches
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: flash=La%20t%C3%A2che%20%5BR%C3%A9viser%20NestJS%5D%20a%20%C3%A9t%C3%A9%20ajout%C3%A9e.; Max-Age=60; Path=/; Expires=Wed, 23 Sep 2026 14:24:23 GMT; HttpOnly; SameSite=Lax
Location: /taches
Content-Type: text/plain; charset=utf-8
Content-Length: 29

Found. Redirecting to /taches

La réponse est une redirection, avec le cookie [flash] (son contenu est encodé). La requête suivante renvoie le cookie ; la réponse l'efface (date d'expiration dans le passé) :

curl -i -c cookies.txt -b cookies.txt http://localhost:3000/taches
HTTP/1.1 200 OK
Set-Cookie: flash=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Tâches</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  ...

Dans le navigateur, après l'ajout d'une tâche :

Image

La barre d'adresse affiche [/taches] : c'est la page GET. On peut la recharger sans risque, le message disparaît. Une saisie vide :

Image

Et le contre-exemple :

Image

Ici, recharger la page provoque un avertissement du navigateur (« confirmer le renvoi du formulaire »), puis ajoute la tâche une seconde fois. Tous les formulaires POST de l'étude de cas suivent le schéma PRG.

4.9. Exemple 21 : liens, boutons et confirmation

Une règle simple guide le choix entre un lien et un bouton :

  • un lien ([<a href>]) produit une requête GET. Une requête GET ne doit rien modifier : les navigateurs, les moteurs de recherche, les antivirus se permettent de suivre des liens tout seuls, pour préparer une page ou l'analyser ;
  • une opération qui modifie des données (ajouter, modifier, supprimer, se déconnecter) est un bouton dans un formulaire POST.

L'exemple 21 gère une liste de contacts : on consulte un contact par un lien, on le supprime par un bouton, avec une fenêtre de confirmation.

Le fichier [src/contacts.controller.ts] :

import { Body, Controller, Get, Param, ParseIntPipe, Post, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { flash } from './flash';
import { ContactsService } from './contacts.service';

@Controller('contacts')
export class ContactsController {
  constructor(private readonly contacts: ContactsService) {}

  // un LIEN (<a href>) : une requête GET, qui ne modifie rien
  @Get()
  @Render('liste')
  liste(): object {
    return { contacts: this.contacts.liste() };
  }

  @Get(':id')
  @Render('fiche')
  fiche(@Param('id', ParseIntPipe) id: number): object {
    return { contact: this.contacts.lire(id) };
  }

  // un BOUTON dans un formulaire POST : une requête qui modifie des données
  @Post(':id')
  enregistrer(
    @Param('id', ParseIntPipe) id: number,
    @Body() corps: { nom: string; telephone: string },
    @Res() res: Response,
  ): void {
    const contact = this.contacts.modifier(id, corps.nom, corps.telephone);
    flash(res, `Le contact [${contact.nom}] a été modifié.`);
    res.redirect('/contacts');
  }

  // même formulaire, autre bouton (attribut formaction) : autre action
  @Post(':id/dupliquer')
  dupliquer(
    @Param('id', ParseIntPipe) id: number,
    @Body() corps: { nom: string; telephone: string },
    @Res() res: Response,
  ): void {
    const copie = this.contacts.ajouter(`${corps.nom} (copie)`, corps.telephone);
    flash(res, `Le contact [${copie.nom}] a été créé.`);
    res.redirect('/contacts');
  }

  @Post(':id/supprimer')
  supprimer(@Param('id', ParseIntPipe) id: number, @Res() res: Response): void {
    const contact = this.contacts.supprimer(id);
    flash(res, `Le contact [${contact.nom}] a été supprimé.`);
    res.redirect('/contacts');
  }
}
  • lignes 11-15 et 17-21 : les deux pages : la liste et la fiche d'un contact. [ParseIntPipe] convertit l'identifiant ; un contact inexistant provoque une 404 (cf. le service) ;
  • lignes 24-29 : l'enregistrement d'une fiche, en PRG ;
  • lignes 36-41 : la duplication, déclenchée par un second bouton du même formulaire (attribut [formaction]) ;
  • lignes 47-52 : la suppression, par un POST.

Le fichier [views/liste.hbs] :

<h1 class="h3">Contacts</h1>
<table class="table align-middle">
  <tbody>
    {{#each contacts}}
      <tr>
        <td>{{nom}}</td>
        <td>{{telephone}}</td>
        <td class="text-end">
          {{!-- consulter : un simple lien --}}
          <a class="btn btn-sm btn-outline-primary" href="/contacts/{{id}}">Voir</a>
          {{!-- supprimer : un formulaire POST, dont l'envoi sera confirmé --}}
          <form method="post" action="/contacts/{{id}}/supprimer" class="d-inline"
                data-confirmation="Supprimer le contact {{nom}} ?">
            <button class="btn btn-sm btn-outline-danger" type="submit">Supprimer</button>
          </form>
        </td>
      </tr>
    {{/each}}
  </tbody>
</table>
  • ligne 10 : consulter : un lien, présenté comme un bouton par [Bootstrap] ;
  • lignes 12-15 : supprimer : un petit formulaire POST, réduit à son bouton. L'attribut [data-confirmation] contient la question à poser avant l'envoi. Les attributs [data-*] sont libres : le HTML les ignore, le [JavaScript] de la page peut les lire.

Le fichier [views/fiche.hbs] :

<h1 class="h3">Contact n° {{contact.id}}</h1>
<form method="post" action="/contacts/{{contact.id}}">
  <div class="mb-2">
    <label class="form-label" for="nom">Nom</label>
    <input class="form-control" id="nom" name="nom" value="{{contact.nom}}">
  </div>
  <div class="mb-3">
    <label class="form-label" for="telephone">Téléphone</label>
    <input class="form-control" id="telephone" name="telephone" value="{{contact.telephone}}">
  </div>
  {{!-- deux boutons d'envoi pour un même formulaire --}}
  <button class="btn btn-primary" type="submit">Enregistrer</button>
  <button class="btn btn-outline-primary" type="submit" formaction="/contacts/{{contact.id}}/dupliquer">Dupliquer</button>
  <a class="btn btn-link" href="/contacts">Retour à la liste</a>
</form>

ligne 13 : deux boutons d'envoi pour un même formulaire.

4.9.1. La fenêtre de confirmation

La boîte de dialogue du navigateur ([window.confirm]) ne peut être ni mise en forme, ni traduite : ses boutons restent dans la langue du navigateur. On la remplace par une fenêtre [Bootstrap], écrite dans la page et cachée tant qu'on n'en a pas besoin :

Le fichier [views/partials/confirmation.hbs] :

{{!-- une seule fenêtre pour toute la page, cachée (d-none) au départ --}}
<div id="modale-confirmation" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="modale-confirmation-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="non">Non, revenir</button>
          <button type="button" class="btn btn-danger" data-action="oui">Oui, confirmer</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • ligne 2 : la classe [d-none] cache la fenêtre ;
  • ligne 3 : le fond grisé ;
  • ligne 11 : l'emplacement de la question ;
  • lignes 14-15 : les deux boutons, repérés par un attribut [data-action].

Un peu de [JavaScript] fait le lien entre les formulaires et la fenêtre :

Le fichier [public/js/app.js] :

// confirmation des formulaires portant l'attribut data-confirmation="..."
// Sans JavaScript, ces formulaires partent simplement sans confirmation.
(() => {
  const modale = document.getElementById('modale-confirmation');
  const message = document.getElementById('modale-confirmation-message');
  let enAttente = null; // le formulaire à envoyer si l'on confirme

  // 1. on intercepte l'envoi des formulaires à confirmer
  document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
    formulaire.addEventListener('submit', (evenement) => {
      evenement.preventDefault(); // l'envoi est suspendu
      enAttente = formulaire;
      message.textContent = formulaire.dataset.confirmation;
      modale.classList.remove('d-none'); // la fenêtre apparaît
    });
  });

  // 2. les deux boutons de la fenêtre
  modale.addEventListener('click', (evenement) => {
    const action = evenement.target.dataset.action;
    if (action === 'oui') {
      enAttente.submit(); // envoi, SANS redéclencher l'événement 'submit'
    } else if (action === 'non') {
      modale.classList.add('d-none');
      enAttente = null;
    }
  });
})();
  • ligne 3 : le code est enveloppé dans une fonction exécutée aussitôt, pour ne pas créer de variables globales ;
  • lignes 9-16 : on intercepte l'envoi de chaque formulaire qui porte l'attribut [data-confirmation]. [preventDefault()] suspend l'envoi ; on mémorise le formulaire, on place la question dans la fenêtre ([textContent], et non [innerHTML] : la question est affichée comme du texte, jamais interprétée comme du HTML) et on affiche la fenêtre ;
  • lignes 19-27 : « Oui » envoie le formulaire mémorisé. La méthode [submit()] d'un formulaire l'envoie sans déclencher l'événement [submit] : il n'y a pas de nouvelle demande de confirmation. « Non » cache la fenêtre.

Si le [JavaScript] est désactivé, les formulaires partent simplement sans confirmation : l'application fonctionne toujours. C'est le principe d'amélioration progressive : le [JavaScript] ajoute un confort, il n'est pas indispensable. L'étude de cas suit ce principe (son script [public/js/app.js] ajoute la gestion du clavier : touche Échap, focus). Les pages obtenues :

Image

Image

Image

Image

Enfin, on vérifie que la suppression ne peut pas être déclenchée par une requête GET :

curl -i http://localhost:3000/contacts/3/supprimer
1
2
3
4
5
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 83

{"message":"Cannot GET /contacts/3/supprimer","error":"Not Found","statusCode":404}
curl -i -X POST http://localhost:3000/contacts/3/supprimer
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: flash=Le%20contact%20%5BPhilippe%20Jandot%5D%20a%20%C3%A9t%C3%A9%20supprim%C3%A9.; Max-Age=60; Path=/; Expires=Wed, 23 Sep 2026 14:24:35 GMT; HttpOnly; SameSite=Lax
Location: /contacts
Content-Type: text/plain; charset=utf-8
Content-Length: 31

Found. Redirecting to /contacts