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. [ASP.NET Core MVC] utilise le moteur de vues [Razor] : une vue est un fichier [.cshtml], du HTML dans lequel on insère du code [C#] précédé du caractère [@]. [Razor] fait partie du framework : il n'y a rien à installer.

4.1. Exemple 13 : une première vue

Le fichier [Program.cs] :

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

// les services de MVC : contrôleurs ET vues Razor
builder.Services.AddControllersWithViews();

var app = builder.Build();
app.MapControllers();
app.Run();
  • ligne 4 : [AddControllersWithViews] remplace [AddControllers] des exemples précédents. Elle ajoute aux services des contrôleurs ceux des vues : le moteur [Razor], la recherche des fichiers de vues, les Tag Helpers (exemple 15).

Le modèle de la vue est une simple classe. Le fichier [Models/PageModele.cs] :

1
2
3
4
namespace Exemple13.Models;

// le modèle de la vue : les données que l'action transmet à la vue
public record PageModele(string Titre, string Message, string Heure);
  • ligne 4 : un [record] est une classe dont les propriétés sont fixées par le constructeur, ici trois chaînes : [Titre], [Message] et [Heure]. C'est une façon concise d'écrire une classe qui ne fait que transporter des données.

Le fichier [Controllers/VuesController.cs] :

using Exemple13.Models;
using Microsoft.AspNetCore.Mvc;

namespace Exemple13.Controllers;

public class VuesController : Controller
{
    // View(modèle) : la vue qui porte le nom de l'action (Views/Vues/Index.cshtml)
    [HttpGet("/")]
    public IActionResult Index()
    {
        var modele = new PageModele(
            "Ma première vue",
            "Cette page a été fabriquée par Razor, sur le serveur.",
            DateTime.Now.ToString("HH:mm:ss"));
        return View(modele);
    }

    // View(nom, modèle) : la vue est choisie par l'action
    [HttpGet("bonjour/{nom}")]
    public IActionResult Bonjour(string nom)
    {
        if (nom == "inconnu")
        {
            Response.StatusCode = 404;
            return View("Inconnu", nom);
        }
        var modele = new PageModele(
            $"Bonjour {nom}",
            "Cette fois, l'action a désigné la vue par son nom.",
            DateTime.Now.ToString("HH:mm:ss"));
        return View("Index", modele);
    }
}

Commentons ce code :

  • ligne 6 : le contrôleur dérive de la classe [Controller], et non plus de [ControllerBase] : [Controller] ajoute à [ControllerBase] tout ce qui concerne les vues (méthode [View], [ViewData], [TempData]...) ;
  • lignes 12-15 : l'action fabrique le modèle de la vue : un titre, un message et l'heure courante ;
  • ligne 16 : [View(modele)] rend un [ViewResult] : l'action ne fabrique plus la réponse, elle désigne une vue et lui transmet un modèle. Sans nom de vue, c'est la vue qui porte le nom de l'action qui est utilisée : [Index] ;
  • lignes 23-26 : quand la vue dépend de la requête, l'action choisit la vue. Ici, le nom [inconnu] affiche la vue [Inconnu], avec le code 404. Le modèle de cette vue est une simple chaîne de caractères ;
  • ligne 32 : l'action [Bonjour] désigne explicitement la vue [Index] par son nom : deux actions partagent la même vue, avec des modèles différents.

Où [ASP.NET Core] cherche-t-il la vue [Index] de l'action [VuesController.Index] ? Par convention, dans deux dossiers, dans cet ordre :

  • [Views/Vues/Index.cshtml] : le dossier qui porte le nom du contrôleur (sans le suffixe [Controller]) ;
  • [Views/Shared/Index.cshtml] : le dossier des vues partagées par tous les contrôleurs (cf. exemple 15).

Si la vue n'est trouvée nulle part, une exception indique les chemins essayés. Les fichiers du projet :

13-premiere-vue/
├── Controllers/
│   └── VuesController.cs
├── Models/
│   └── PageModele.cs
├── Views/
│   ├── Vues/
│   │   ├── Inconnu.cshtml
│   │   └── Index.cshtml
│   └── _ViewImports.cshtml
├── appsettings.json
├── Exemple13.csproj
└── Program.cs

Le fichier [Views/_ViewImports.cshtml] :

@* les "using" communs à toutes les vues du dossier Views *@
@using Exemple13.Models
  • ligne 1 : [@* ... *@] est un commentaire [Razor] : il n'apparaît pas dans la page produite (contrairement à un commentaire HTML [<!-- -->]) ;
  • ligne 2 : les directives du fichier [_ViewImports.cshtml] s'appliquent à toutes les vues de son dossier et de ses sous-dossiers. [@using Exemple13.Models] permet d'écrire [PageModele] au lieu de [Exemple13.Models.PageModele] dans les vues.

Le fichier [Views/Vues/Index.cshtml] :

@* le type du modèle de cette vue *@
@model PageModele
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>@Model.Titre</title>
</head>
<body>
  <h1>@Model.Titre</h1>
  <p>@Model.Message</p>
  <p>Page fabriquée à @Model.Heure.</p>
</body>
</html>
  • ligne 2 : la directive [@model] déclare le type du modèle de la vue. Dans la vue, la propriété [Model] est alors de type [PageModele], et le compilateur vérifie que [Model.Titre] existe ;
  • lignes 7-12 : chaque expression [@Model.Titre] est remplacée par la valeur correspondante du modèle. [Razor] reconnaît seul la fin d'une expression : dans [@Model.Heure.], le point final est suivi d'une balise, ce n'est donc pas un accès à une propriété.

Une vue est un fichier [C#] déguisé : au moment de la compilation du projet ([dotnet build]), chaque fichier [.cshtml] est traduit en une classe [C#], compilée avec le reste de l'application. Une erreur dans une vue ([@Model.Titr]) est donc signalée dès la compilation. Le résultat, à l'URL http://localhost:5000/ :

Image

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

curl http://localhost:5000/
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Ma premi&#xE8;re vue</title>
</head>
<body>
  <h1>Ma premi&#xE8;re vue</h1>
  <p>Cette page a &#xE9;t&#xE9; fabriqu&#xE9;e par Razor, sur le serveur.</p>
  <p>Page fabriquée à 12:12:36.</p>
</body>
</html>

On remarque que les caractères accentués insérés par une expression [@] ont été codés : [&#xE8;] est le code du caractère [è]. Le texte écrit directement dans la vue ([Page fabriquée à]) est resté tel quel. Le navigateur affiche correctement les deux, mais le code HTML est peu lisible. Nous verrons à l'exemple 15 comment l'éviter. L'URL http://localhost:5000/bonjour/martin affiche la même vue avec un autre modèle :

Image

et http://localhost:5000/bonjour/inconnu affiche une autre vue :

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

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

Le fichier [Views/Vues/Inconnu.cshtml] :

@* le modèle de cette vue est une simple chaîne : le nom demandé *@
@model string
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [@Model] ici.</h1>
</body>
</html>
  • ligne 2 : le modèle de cette vue est une chaîne de caractères ;
  • ligne 10 : [@Model] est la chaîne elle-même.

4.2. Exemple 14 : le langage Razor

[Razor] n'est pas un langage de modèles « sans logique » comme [Handlebars] : on peut écrire du [C#] dans une vue (conditions, boucles, variables, appels de méthodes). Cette liberté demande de la discipline : la vue doit rester une mise en forme ; les calculs et les décisions appartiennent à l'action et aux services. L'exemple 14 fait le tour de la syntaxe. Le modèle, fichier [Models/BasesModele.cs] :

namespace Exemple14.Models;

public record Produit(string Nom, decimal Prix, bool EnStock);

public record Adresse(string Rue, string Ville);

public class BasesModele
{
    public string Titre { get; set; } = "";
    public string Nom { get; set; } = "";
    public string? Surnom { get; set; }
    public string Html { get; set; } = "";
    public bool Connecte { get; set; }
    public string Devise { get; set; } = "";
    public List<Produit> Produits { get; set; } = [];
    public List<Produit> Panier { get; set; } = [];
    public Adresse Adresse { get; set; } = new("", "");
}

Le fichier [Controllers/BasesController.cs] :

using Exemple14.Models;
using Microsoft.AspNetCore.Mvc;

namespace Exemple14.Controllers;

public class BasesController : Controller
{
    [HttpGet("/")]
    public IActionResult Index()
    {
        var modele = new BasesModele
        {
            Titre = "Les bases de Razor",
            Nom = "Martin",
            Surnom = null,
            Html = "<b>ceci est du HTML</b>",
            Connecte = true,
            Devise = "€",
            Produits =
            [
                new Produit("Clavier", 25m, true),
                new Produit("Souris", 12.5m, false),
                new Produit("Écran", 149m, true),
            ],
            Panier = [],
            Adresse = new Adresse("12 rue des Lilas", "Angers"),
        };
        return View(modele);
    }
}
  • lignes 11-26 : l'action remplit le modèle : quelques chaînes, un booléen, une liste de trois produits, un panier vide et une adresse. [Surnom] est volontairement [null].

La vue, fichier [Views/Bases/Index.cshtml] :

@model BasesModele
@* un bloc de code C# : il ne produit rien dans la page *@
@{
    var total = Model.Produits.Sum(p => p.Prix);
    var nombre = Model.Produits.Count;
}
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>@Model.Titre</title>
</head>
<body>
  <h1>@Model.Titre</h1>
  @* 1. afficher une valeur du modèle ; une valeur null n'affiche rien *@
  <p>1. Nom : @Model.Nom - surnom : [@Model.Surnom]</p>
  @* 2. @ échappe le HTML, @Html.Raw l'insère tel quel *@
  <p>2a. @Model.Html</p>
  <p>2b. @Html.Raw(Model.Html)</p>
  @* 3. une condition *@
  @if (Model.Connecte)
  {
    <p>3. Vous êtes connecté.</p>
  }
  else
  {
    <p>3. Vous n'êtes pas connecté.</p>
  }
  @* 4. une boucle ; les variables de la boucle sont des variables C# *@
  <p>4. Nos produits :</p>
  <ul>
    @for (var i = 0; i < Model.Produits.Count; i++)
    {
      var produit = Model.Produits[i];
      <li>n° @i : @produit.Nom, @produit.Prix @Model.Devise
        @(produit.EnStock ? "(en stock)" : "(épuisé)")
        @if (i == 0) { <text>- le premier</text> }
        @if (i == Model.Produits.Count - 1) { @:- le dernier, vu par @Model.Nom
        }
      </li>
    }
  </ul>
  @* 5. la liste vide *@
  <p>5. Votre panier :
    @if (Model.Panier.Count == 0)
    {
      <text>il est vide.</text>
    }
    else
    {
      foreach (var p in Model.Panier) { <span>@p.Nom</span> }
    }
  </p>
  @* 6. une expression explicite @( ... ), et les variables du bloc @{ } *@
  <p>6. @nombre produits, pour un total de @(total) @Model.Devise ; prix moyen : @((total / nombre).ToString("0.00")) @Model.Devise</p>
  <p>7. Adresse : @Model.Adresse.Rue, @Model.Adresse.Ville.ToUpper()</p>
  <p>8. Une adresse électronique : contact@exemple.fr</p>
</body>
</html>

Commentons ce code :

  • lignes 2-5 : un bloc de code [@{ ... }] contient des instructions [C#]. Il ne produit rien dans la page. Les variables qu'il déclare ([total], [nombre]) sont utilisables dans la suite de la vue ;
  • ligne 16 : une valeur [null] ([@Model.Surnom]) n'affiche rien, sans erreur ;
  • lignes 18-19 : une expression [@...] échappe les caractères spéciaux du HTML ([<] devient [&lt;]...) : le texte s'affiche tel quel. [@Html.Raw(...)] l'insère sans échappement : il est interprété comme du HTML. On n'utilise [Html.Raw] 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], [cross-site scripting]) ;
  • lignes 21-27 : une condition. Après [@if (...)], le contenu des accolades est du HTML : [Razor] reconnaît le début d'une balise et repasse en mode HTML. On revient au [C#] par [@] ;
  • lignes 32-40 : une boucle [for] [C#]. [var produit = ...] est une instruction [C#], [<li>] du HTML : [Razor] alterne les deux sans marque particulière ;
  • ligne 36 : une expression explicite [@( ... )]. Les parenthèses sont nécessaires dès que l'expression contient des espaces ou des opérateurs (ici l'opérateur conditionnel [? :]) ;
  • ligne 37 : pour écrire du texte sans balise dans un bloc [C#], on l'entoure de la balise spéciale [<text>], qui n'apparaît pas dans la page ;
  • ligne 38 : [@:] fait la même chose pour le reste de la ligne ;
  • lignes 45-51 : le cas de la liste vide est un simple [if]. Dans le bloc [else], déjà en mode [C#], [foreach] s'écrit sans [@] ;
  • ligne 55 : les variables du bloc [@{ }] et des expressions explicites, dont un appel de méthode : [ToString("0.00")] formate un nombre avec deux décimales ;
  • ligne 56 : un chemin pointé [@Model.Adresse.Rue], et un appel de méthode [ToUpper()] ;
  • ligne 57 : [Razor] sait qu'un [@] placé entre deux caractères alphanumériques n'est pas du code : [contact@exemple.fr] est une adresse électronique. Pour écrire un [@] qui serait ambigu, on le double : [@@].

Le résultat, à l'URL http://localhost:5000/ :

Image

Le code HTML produit :

curl http://localhost:5000/
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Les bases de Razor</title>
</head>
<body>
  <h1>Les bases de Razor</h1>
  <p>1. Nom : Martin - surnom : []</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 &#x20AC;
        (en stock)
- le premier      </li>
      <li>n° 1 : Souris, 12.5 &#x20AC;
        (&#xE9;puis&#xE9;)
      </li>
      <li>n° 2 : &#xC9;cran, 149 &#x20AC;
        (en stock)
 - le dernier, vu par Martin
      </li>
  </ul>
  <p>5. Votre panier :
il est vide.  </p>
  <p>6. 3 produits, pour un total de 186.5 &#x20AC; ; prix moyen : 62.17 &#x20AC;</p>
  <p>7. Adresse : 12 rue des Lilas, ANGERS</p>
  <p>8. Une adresse électronique : contact@exemple.fr</p>
</body>
</html>

On remarque les espaces et les lignes blanches laissés par les instructions [C#] : ils sont sans effet sur l'affichage. On retrouve aussi le codage des caractères accentués issus d'expressions ([&#x20AC;] pour [€], [&#xE9;] pour [é]), que corrige l'exemple suivant.

4.3. Exemple 15 : gabarit, vues partielles, fichiers statiques

Les pages d'un site partagent un gabarit commun (entête, barre de navigation, pied de page). [Razor] appelle ce gabarit un layout : chaque vue y est insérée. Les morceaux de page réutilisables sont des vues partielles. Enfin, les pages ont besoin de fichiers statiques (feuilles de style, images, scripts), servis tels quels, sans passer par un contrôleur. Les fichiers de l'exemple :

15-gabarit/
├── Controllers/
│   └── PagesController.cs
├── Views/
│   ├── Pages/
│   │   ├── Accueil.cshtml
│   │   ├── Brute.cshtml
│   │   └── Equipe.cshtml
│   ├── Shared/
│   │   ├── _Carte.cshtml
│   │   ├── _Layout.cshtml
│   │   └── _Navbar.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── wwwroot/
│   └── css/
│       └── styles.css
├── appsettings.json
├── Exemple15.csproj
└── Program.cs

Le fichier [Program.cs] :

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Razor écrit tels quels les caractères accentués (et non "&#xE9;") ;
// < > & " ' restent échappés
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));

var app = builder.Build();

// 1. les fichiers statiques du dossier wwwroot : wwwroot/css/styles.css est servi en /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, partagé par les exemples : le dossier exemples/commun/bootstrap est servi sous /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();

Commentons ce code :

  • ligne 9 : le service [HtmlEncoder] est celui qu'utilise [Razor] pour échapper les expressions [@]. Par défaut, il ne laisse passer que les caractères de l'alphabet latin de base : c'est ce qui codait [é] en [&#xE9;]. On remplace ce service par un encodeur qui laisse passer tous les caractères ([UnicodeRanges.All]). Les caractères dangereux ([<], [>], [&], ["], [']) restent échappés : la protection contre l'injection de HTML est intacte. Tous les exemples suivants, et l'étude de cas, ont cette ligne ;
  • ligne 14 : un middleware (cf. chapitre 7) qui sert les fichiers du dossier [wwwroot] du projet : le fichier [wwwroot/css/styles.css] est servi à l'URL [/css/styles.css]. Si le fichier n'existe pas, la requête continue son chemin vers les contrôleurs ;
  • lignes 16-19 : un second service de fichiers statiques. Le framework CSS [Bootstrap] est commun à tous les exemples : il est rangé une seule fois, dans le dossier [exemples/commun/bootstrap], et servi sous le préfixe [/bootstrap]. [PhysicalFileProvider] désigne un dossier du disque ; [ContentRootPath] est le dossier du projet. Ainsi, [exemples/commun/bootstrap/css/bootstrap.min.css] est servi à l'URL [/bootstrap/css/bootstrap.min.css]. L'étude de cas, elle, range [Bootstrap] dans son propre dossier [wwwroot].

Vérifions :

curl -i http://localhost:5000/css/styles.css
HTTP/1.1 200 OK
Content-Length: 422
Content-Type: text/css

/* quelques réglages en plus de Bootstrap */
body {
  background-color: #f5f7fa;
}

/* le pied de page du gabarit */
.pied {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* les messages d'erreur, en bas de page, sur fond rose léger */
.zone-erreur {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

Le fichier [Views/_ViewStart.cshtml] :

1
2
3
4
@* exécuté avant chaque vue : toutes les vues utilisent le gabarit _Layout *@
@{
    Layout = "_Layout";
}
  • ligne 3 : le fichier [_ViewStart.cshtml] est exécuté avant chaque vue de son dossier et de ses sous-dossiers. Il fixe la propriété [Layout] : toutes les vues utilisent le gabarit [_Layout], cherché dans le dossier de la vue puis dans [Views/Shared].

Le fichier [Views/_ViewImports.cshtml] :

1
2
3
@* les Tag Helpers d'ASP.NET Core : des balises HTML "enrichies", comme <partial> *@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using Exemple15.Controllers
  • ligne 2 : active les Tag Helpers fournis par [ASP.NET Core] : des balises ou des attributs HTML traités par le serveur. La balise [<partial>] en est un. Le [*] signifie « tous les Tag Helpers de l'assemblage » [Microsoft.AspNetCore.Mvc.TagHelpers] ;
  • ligne 3 : le type [Membre] est déclaré dans l'espace de noms des contrôleurs.

Le fichier [Views/Shared/_Layout.cshtml] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  @* ViewData["Titre"] : une valeur déposée par la vue, lue par le gabarit *@
  <title>@ViewData["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">
    <partial name="_Navbar" />
    @* la vue demandée par l'action est insérée ici *@
    @RenderBody()
    @* Context : la requête en cours (HttpContext), accessible à toute vue *@
    <footer class="pied">Page [@Context.Request.Path] - © @DateTime.Now.Year</footer>
  </div>
</body>
</html>
  • ligne 7 : [ViewData] est un dictionnaire partagé par une vue et son gabarit (et par l'action, qui peut aussi y déposer des valeurs). La vue y dépose son titre, le gabarit l'affiche ;
  • lignes 8-9 : les feuilles de style, servies par [UseStaticFiles] ;
  • ligne 13 : insertion de la vue partielle [_Navbar], cherchée dans le dossier de la vue puis dans [Views/Shared] ;
  • ligne 15 : l'emplacement où [Razor] insère la vue demandée par l'action ;
  • ligne 17 : [Context] est l'objet [HttpContext] de la requête en cours (cf. exemple 05), accessible à toute vue : le gabarit affiche le chemin demandé, sans que chaque action ait à le transmettre.

Le fichier [Views/Shared/_Navbar.cshtml] :

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/Shared/_Carte.cshtml] :

1
2
3
4
5
6
7
8
@* une "carte" Bootstrap ; son modèle : un Membre (nom, fonction) *@
@model Membre
<div class="card">
  <div class="card-body">
    <h5 class="card-title">@Model.Nom</h5>
    <p class="card-text">@Model.Fonction</p>
  </div>
</div>

Par convention, le nom d'une vue partielle ou d'un gabarit commence par [_] : ce ne sont pas des pages complètes. La vue partielle [_Carte] a son propre modèle (ligne 2), un nom et une fonction. Le type [Membre] est déclaré dans le contrôleur. Le fichier [Controllers/PagesController.cs] :

using Microsoft.AspNetCore.Mvc;

namespace Exemple15.Controllers;

public record Membre(string Nom, string Fonction);

public class PagesController : Controller
{
    [HttpGet("/")]
    public IActionResult Accueil()
    {
        return View();
    }

    [HttpGet("equipe")]
    public IActionResult Equipe()
    {
        List<Membre> membres =
        [
            new Membre("Marie Pélissier", "Directrice"),
            new Membre("Jacques Bromard", "Développeur"),
            new Membre("Philippe Jandot", "Graphiste"),
        ];
        return View(membres);
    }

    [HttpGet("brute")]
    public IActionResult Brute()
    {
        return View();
    }
}
  • ligne 5 : le type des membres de l'équipe ;
  • ligne 12 : l'action [Accueil] n'a pas de modèle : [View()] sans argument ;
  • lignes 18-24 : l'action [Equipe] transmet une liste de membres.

La vue partielle est utilisée de deux façons. Le fichier [Views/Pages/Accueil.cshtml] :

1
2
3
4
5
6
7
@{
    ViewData["Titre"] = "Accueil";
}
<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.cshtml</code>.</p>
<partial name="_Carte" model='new Membre("Une vue partielle", "insérée avec un modèle fabriqué sur place")' />
  • ligne 2 : la vue dépose son titre dans [ViewData], pour le gabarit ;
  • ligne 7 : l'attribut [model] de la balise [<partial>] fournit le modèle de la vue partielle : ici, un objet fabriqué sur place. C'est une expression [C#] : on l'a écrite entre apostrophes pour pouvoir utiliser des guillemets à l'intérieur.

Le fichier [Views/Pages/Equipe.cshtml] :

@model List<Membre>
@{
    ViewData["Titre"] = "Notre équipe";
}
<h1>Notre équipe</h1>
<div class="row g-3">
  @foreach (var membre in Model)
  {
    <div class="col-md-4">
      @* la vue partielle reçoit comme modèle le membre courant *@
      <partial name="_Carte" model="membre" />
    </div>
  }
</div>
  • ligne 11 : la vue partielle reçoit comme modèle le membre courant de la boucle.

Le fichier [Views/Pages/Brute.cshtml] :

@* Layout = null : cette vue n'utilise pas le gabarit, c'est une page complète *@
@{
    Layout = null;
}
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Page brute</title>
</head>
<body>
  <p>Cette page n'utilise pas le gabarit : ni Bootstrap, ni barre de navigation.</p>
  <p><a href="/">Retour à l'accueil</a></p>
</body>
</html>
  • ligne 3 : fixer [Layout] à [null] dans une vue annule le choix de [_ViewStart.cshtml] : la vue [Brute] est une page HTML complète. On pourrait aussi désigner un autre gabarit ([Layout = "_Autre"]).

Les pages obtenues, aux URL http://localhost:5000/, http://localhost:5000/equipe et http://localhost:5000/brute :

Image

Image

Image

Le gabarit, les vues partielles et [ViewData] sont exactement ce qu'utilise l'étude de cas : un gabarit [_Layout.cshtml], des vues partielles pour la barre de navigation, les messages, les fenêtres de confirmation.

4.4. Exemple 16 : les helpers

Une vue a souvent besoin de petites fonctions de mise en forme : formater une date ou un prix, mettre un texte en majuscules, fabriquer un badge HTML... Avec [Razor], ce sont de simples méthodes [C#], appelées dans une expression [@]. [ASP.NET Core] propose en plus les Tag Helpers : du code [C#] qui intervient sur une balise HTML au moment où la page est produite.

Le fichier [Aides/Formats.cs] :

using System.Globalization;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;

namespace Exemple16.Aides;

// des fonctions C# ordinaires, appelables depuis les vues : @Formats.Monnaie(...)
public static class Formats
{
    // "clavier" -> "CLAVIER"
    public static string Majuscules(string? texte) => (texte ?? "").ToUpperInvariant();

    // un montant dans une devise, au format de la langue courante (CultureInfo.CurrentCulture)
    // "C" = currency ; l'objet NumberFormatInfo fixe le symbole de la devise
    public static string Monnaie(decimal montant, string devise = "EUR")
    {
        var format = (NumberFormatInfo)CultureInfo.CurrentCulture.NumberFormat.Clone();
        format.CurrencySymbol = devise == "USD" ? "$" : "€";
        return montant.ToString("C", format);
    }

    // une date en toutes lettres, dans la langue courante : "mercredi 23 septembre 2026"
    public static string Date(DateTime quand) => quand.ToString("D", CultureInfo.CurrentCulture);

    // un helper qui fabrique du HTML : il rend un IHtmlContent, que Razor n'échappe pas.
    // C'est alors au helper d'échapper les données qu'il insère (HtmlEncoder)
    public static IHtmlContent Badge(int stock)
    {
        var classe = stock > 0 ? "text-bg-success" : "text-bg-danger";
        var texte = HtmlEncoder.Default.Encode(stock > 0 ? $"{stock} en stock" : "épuisé");
        return new HtmlString($"<span class=\"badge {classe}\">{texte}</span>");
    }
}

Commentons ce code :

  • ligne 8 : une classe statique : ses méthodes s'appellent sans créer d'objet, [Formats.Majuscules(...)] ;
  • ligne 11 : la mise en majuscules. [ToUpperInvariant] ne dépend pas de la langue de l'utilisateur ;
  • lignes 15-19 : un montant dans une devise. Le format ["C"] ([currency]) met en forme un montant selon la culture courante ([CultureInfo.CurrentCulture]) : séparateur décimal, séparateur des milliers, place du symbole. On copie ([Clone]) les règles de la culture pour y changer le symbole de la devise ;
  • ligne 23 : une date en toutes lettres (format ["D"]), dans la langue de la culture courante ;
  • lignes 27-31 : un helper qui fabrique du HTML. Ce que rend une expression [@] est échappé, sauf si c'est un [IHtmlContent] : du HTML déjà fabriqué. [HtmlString] en est l'implémentation la plus simple. Le helper doit alors échapper lui-même les données qu'il insère dans son HTML ([HtmlEncoder.Default.Encode]).

Le fichier [TagHelpers/ActifTagHelper.cs] :

using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace Exemple16.TagHelpers;

// un Tag Helper : il s'applique aux balises <a> qui portent l'attribut "actif"
// <a href="/produits" actif="/produits"> reçoit la classe "active" si la page
// courante est sous ce chemin
[HtmlTargetElement("a", Attributes = "actif")]
public class ActifTagHelper(IHttpContextAccessor accesseur) : TagHelper
{
    // la valeur de l'attribut actif="..."
    public string Actif { get; set; } = "";

    public override void Process(TagHelperContext contexte, TagHelperOutput sortie)
    {
        var chemin = accesseur.HttpContext?.Request.Path.Value ?? "/";
        var estActif = Actif == "/" ? chemin == "/" : chemin.StartsWith(Actif, StringComparison.Ordinal);
        if (estActif)
        {
            sortie.AddClass("active", HtmlEncoder.Default); // ajoute la classe à l'attribut class
        }
    }
}

Commentons ce code :

  • ligne 10 : le Tag Helper s'applique aux balises [<a>] qui portent l'attribut [actif] ;
  • ligne 11 : un Tag Helper dérive de la classe [TagHelper]. C'est un service : il reçoit par injection un [IHttpContextAccessor], qui donne accès à la requête en cours ;
  • ligne 14 : la valeur de l'attribut [actif="..."] est placée dans la propriété de même nom ;
  • lignes 16-22 : la méthode [Process] est appelée pour chaque balise concernée. Si le chemin de la page commence par la valeur de l'attribut, on ajoute la classe CSS [active] à la balise ([AddClass]). L'attribut [actif] lui-même n'apparaît pas dans la page produite : le Tag Helper l'a consommé.

Le fichier [Program.cs] :

using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// IHttpContextAccessor : donne accès à la requête en cours (utilisé par ActifTagHelper)
builder.Services.AddHttpContextAccessor();

var app = builder.Build();

// un middleware : la langue de formatage vient du paramètre ?lang=en de l'URL
app.Use(async (contexte, suite) =>
{
    var langue = contexte.Request.Query["lang"] == "en" ? "en-US" : "fr-FR";
    CultureInfo.CurrentCulture = new CultureInfo(langue);
    await suite(contexte); // on passe la main au middleware suivant
});

app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();
  • ligne 10 : enregistre le service [IHttpContextAccessor], utilisé par le Tag Helper ;
  • lignes 15-19 : un middleware écrit directement dans [Program.cs], exécuté pour chaque requête. Il choisit la culture selon le paramètre [lang] de l'URL, puis passe la main au middleware suivant ([suite]). C'est une façon simple de tester ; le chapitre suivant fera mieux.

Le fichier [Views/_ViewImports.cshtml] :

1
2
3
4
5
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@* nos propres Tag Helpers, ceux de l'assemblage Exemple16 *@
@addTagHelper *, Exemple16
@using Exemple16.Aides
@using Exemple16.Controllers
  • ligne 3 : on active les Tag Helpers de notre propre projet (l'assemblage [Exemple16]) ;
  • ligne 4 : les vues peuvent écrire [Formats.Monnaie(...)].

Le fichier [Views/Shared/_Navbar.cshtml] :

<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Helpers</span>
  <div class="navbar-nav">
    @* 1. une condition écrite dans la vue *@
    <a class="nav-link @(Context.Request.Path == "/" ? "active" : "")" href="/">Accueil</a>
    @* 2. notre Tag Helper : l'attribut actif="..." *@
    <a class="nav-link" href="/produits" actif="/produits">Produits</a>
    @* 3. un Tag Helper d'ASP.NET : asp-action fabrique l'URL de l'action *@
    <a class="nav-link" asp-controller="Pages" asp-action="Produits" asp-route-lang="en">Products (en)</a>
  </div>
</nav>
  • lignes 4-5 : première façon de mettre en évidence le lien de la page courante : une condition écrite dans la vue ;
  • ligne 7 : deuxième façon, plus concise : notre Tag Helper. C'est celle de l'étude de cas ;
  • ligne 9 : un Tag Helper d'[ASP.NET Core]. Les attributs [asp-controller] et [asp-action] désignent une action ; le Tag Helper calcule son URL à partir des routes (ici [/produits]) et l'écrit dans l'attribut [href]. [asp-route-lang="en"] ajoute un paramètre, placé dans l'URL ([?lang=en]) puisque la route ne contient pas de paramètre [lang]. Si l'on change la route de l'action, le lien suit.

Le code HTML de la barre de navigation, à l'URL [/produits] :

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">
    <a class="nav-link " href="/">Accueil</a>
    <a class="nav-link active" href="/produits">Produits</a>
    <a class="nav-link" href="/produits?lang=en">Products (en)</a>
  </div>
</nav>

Le fichier [Views/Pages/Produits.cshtml] :

@model List<Produit>
@{
    ViewData["Titre"] = "Produits";
}
<h1>Produits</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>
    @foreach (var produit in Model)
    {
      <tr>
        <td>@Formats.Majuscules(produit.Nom)</td>
        <td>@Formats.Monnaie(produit.Prix)</td>
        <td>@Formats.Monnaie(produit.Prix, "USD")</td>
        <td>@Formats.Badge(produit.Stock)</td>
      </tr>
    }
  </tbody>
</table>
  • lignes 12-15 : les helpers sont appelés dans des expressions [@]. [Badge] rend du HTML, inséré sans échappement.

Les pages obtenues, en français (http://localhost:5000/, http://localhost:5000/produits) puis en anglais (http://localhost:5000/produits?lang=en) :

Image

Image

Image

Les prix changent de format avec la culture : [1 149,90 €] en français, [€1,149.90] en anglais.

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 [Models/Livre.cs] :

1
2
3
4
5
6
namespace Exemple17.Models;

public record Livre(string Titre, string Auteur, string Genre);

// le modèle de la vue : la saisie (pour la réafficher), les genres, les résultats
public record RechercheModele(string MotCle, string Genre, string[] Genres, List<Livre> Resultats);
  • ligne 6 : le modèle de la vue contient les valeurs saisies ([MotCle], [Genre]), pour que le formulaire les réaffiche, la liste des genres, et les résultats.

Le fichier [Controllers/LivresController.cs] :

using Exemple17.Models;
using Microsoft.AspNetCore.Mvc;

namespace Exemple17.Controllers;

public class LivresController : Controller
{
    private static readonly List<Livre> Livres =
    [
        new Livre("Le Rouge et le Noir", "Stendhal", "roman"),
        new Livre("Les Fleurs du mal", "Baudelaire", "poésie"),
        new Livre("Madame Bovary", "Flaubert", "roman"),
        new Livre("Alcools", "Apollinaire", "poésie"),
        new Livre("Le Cid", "Corneille", "théâtre"),
    ];

    private static readonly string[] Genres = ["roman", "poésie", "théâtre"];

    // GET / et GET /recherche?motCle=...&genre=... : le formulaire ET ses résultats
    [HttpGet("/")]
    [HttpGet("recherche")]
    public IActionResult Recherche(string? motCle, string? genre)
    {
        var mot = (motCle ?? "").Trim().ToLower();
        var resultats = Livres
            .Where(l => mot == "" || l.Titre.ToLower().Contains(mot) || l.Auteur.ToLower().Contains(mot))
            .Where(l => string.IsNullOrEmpty(genre) || l.Genre == genre)
            .ToList();
        return View(new RechercheModele(motCle ?? "", genre ?? "", Genres, resultats));
    }
}
  • lignes 8-14 : les livres, en mémoire ;
  • lignes 20-21 : deux attributs de route : l'action traite [GET /] et [GET /recherche] ;
  • ligne 22 : les paramètres [motCle] et [genre] sont liés aux paramètres de même nom de l'URL. À la première visite, le formulaire n'a pas encore été envoyé : ils valent [null] ;
  • lignes 25-28 : le filtrage, avec [LINQ]. Un mot-clé vide ou un genre vide ne filtrent rien ;
  • ligne 29 : le modèle de la vue.

Le fichier [Views/Livres/Recherche.cshtml] :

@model RechercheModele
<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="@Model.MotCle" : le champ réaffiche ce qui a été saisi *@
    <input class="form-control" id="motCle" name="motCle" value="@Model.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>
      @foreach (var genre in Model.Genres)
      {
        @* un attribut booléen : écrit si la condition est vraie, omis sinon *@
        <option value="@genre" selected="@(genre == Model.Genre)">@genre</option>
      }
    </select>
  </div>
  <div class="col-auto">
    <button class="btn btn-primary" type="submit">Rechercher</button>
  </div>
</form>
<p>@Model.Resultats.Count livre(s) trouvé(s).</p>
<ul>
  @foreach (var livre in Model.Resultats)
  {
    <li><i>@livre.Titre</i>, @livre.Auteur (@livre.Genre)</li>
  }
</ul>
  • ligne 4 : [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 8 : l'attribut [value] réaffiche la saisie ;
  • ligne 17 : dans la liste déroulante, l'option égale au genre choisi reçoit l'attribut [selected]. [Razor] traite les attributs booléens de façon particulière : si la valeur est [true], il écrit [selected="selected"] ; si elle est [false], il n'écrit pas l'attribut du tout. C'est exactement ce que demande le HTML, pour qui la seule présence de l'attribut [selected] suffit à sélectionner l'option.

Au premier affichage (http://localhost:5000/), 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, qu'[ASP.NET Core] décode avant la liaison de modèle.

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 [Services/ListesService.cs] :

namespace Exemple18.Services;

public record Pays(string Code, string Nom);

// 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
public class ListesService
{
    public string[] Titres { get; } = ["Mr", "Mme", "Melle"];

    public Pays[] Pays { get; } =
    [
        new Pays("fr", "France"),
        new Pays("be", "Belgique"),
        new Pays("ch", "Suisse"),
        new Pays("ca", "Canada"),
    ];

    public string[] Loisirs { get; } = ["lecture", "musique", "sport", "cinéma"];

    public string[] Langues { get; } = ["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 (un singleton, cf. chapitre 6) est l'endroit naturel pour les garder. Il est enregistré dans [Program.cs] :

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Exemple18.Services;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Razor écrit tels quels les caractères accentués (et non "&#xE9;") ;
// < > & " ' restent échappés
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// les listes du formulaire : les mêmes pour tous, un seul objet (singleton)
builder.Services.AddSingleton<ListesService>();

var app = builder.Build();

// 1. les fichiers statiques du dossier wwwroot : wwwroot/css/styles.css est servi en /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, partagé par les exemples : le dossier exemples/commun/bootstrap est servi sous /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();
  • ligne 12 : une seule instance de [ListesService], partagée par toutes les requêtes.

Le fichier [Models/Saisie.cs] :

namespace Exemple18.Models;

// ce que l'utilisateur a saisi : la liaison de modèle remplit chaque propriété
// avec le champ de même nom (les tableaux reçoivent toutes les valeurs cochées)
public class Saisie
{
    public string? Nom { get; set; }
    public string? MotDePasse { get; set; }
    public string? Commentaire { get; set; }
    public string? Titre { get; set; }
    public string[] Loisirs { get; set; } = [];
    public string? Pays { get; set; }
    public string[] Langues { get; set; } = [];
    public DateOnly? Naissance { get; set; }
    public string? Jeton { get; set; }
}

// une option d'une liste, et son état (cochée / sélectionnée)
public record Option(string Valeur, string Libelle, bool Choisie);

// le modèle de la vue
public class FormulaireModele
{
    public required Saisie Saisie { get; init; }
    public required List<Option> Titres { get; init; }
    public required List<Option> Loisirs { get; init; }
    public required List<Option> Pays { get; init; }
    public required List<Option> Langues { get; init; }
    public string? Recu { get; init; } // ce que le serveur a reçu, pour l'afficher
}

Commentons ce code :

  • lignes 5-15 : la saisie. La liaison de modèle remplit chaque propriété avec le champ de même nom. Les cases à cocher et la liste multiple sont des tableaux ([string[]]) : ils reçoivent toutes les valeurs envoyées (cf. exemple 10). La date de naissance est convertie en [DateOnly] ;
  • ligne 19 : une option d'une liste : sa valeur, son libellé, et un booléen qui dit si elle est cochée ou sélectionnée ;
  • lignes 22-29 : le modèle de la vue. Le mot-clé [required] oblige à fournir la propriété à la création de l'objet ; [init] interdit de la modifier ensuite.

Le fichier [Controllers/FormulaireController.cs] :

using Exemple18.Models;
using Exemple18.Services;
using Microsoft.AspNetCore.Mvc;

namespace Exemple18.Controllers;

[Route("formulaire")]
public class FormulaireController(ListesService listes) : Controller
{
    // GET : le formulaire, avec des valeurs initiales
    [HttpGet("")]
    public IActionResult Afficher()
    {
        var initial = new Saisie { Titre = "Mme", Pays = "fr", Langues = ["français"], Jeton = "A7X2" };
        return View("Formulaire", Modele(initial, null));
    }

    // POST : la liaison de modèle remplit [saisie] ; on réaffiche le formulaire
    [HttpPost("")]
    public IActionResult Traiter(Saisie saisie)
    {
        // ce que le navigateur a réellement envoyé, champ par champ
        var recu = string.Join("\n", Request.Form.Select(c => $"{c.Key} = [{string.Join(", ", c.Value.ToArray())}]"));
        return View("Formulaire", Modele(saisie, recu));
    }

    // le modèle de la vue : les listes, chaque option étant marquée cochée /
    // sélectionnée selon la saisie. La vue n'aura plus qu'à tester un booléen.
    private FormulaireModele Modele(Saisie saisie, string? recu) => new()
    {
        Saisie = saisie,
        Titres = listes.Titres.Select(t => new Option(t, t, t == saisie.Titre)).ToList(),
        Loisirs = listes.Loisirs.Select(l => new Option(l, l, saisie.Loisirs.Contains(l))).ToList(),
        Pays = listes.Pays.Select(p => new Option(p.Code, p.Nom, p.Code == saisie.Pays)).ToList(),
        Langues = listes.Langues.Select(l => new Option(l, l, saisie.Langues.Contains(l))).ToList(),
        Recu = recu,
    };
}

Commentons ce code :

  • ligne 8 : le service des listes est injecté ;
  • lignes 11-15 : [GET /formulaire] affiche le formulaire avec des valeurs initiales ;
  • lignes 19-24 : [POST /formulaire] reçoit la saisie, remplie par la liaison de modèle, et réaffiche le formulaire. [Request.Form] donne en plus accès aux champs tels qu'ils ont été envoyés : on en fait un texte, une ligne par champ, pour l'afficher ;
  • lignes 29-36 : 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'aura plus qu'à tester un booléen.

Le fichier [Views/Formulaire/Formulaire.cshtml] :

@model FormulaireModele
<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="@Model.Saisie.Jeton">

  <div class="mb-2">
    <span class="form-label me-3">Titre</span>
    @foreach (var titre in Model.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-@titre.Valeur"
               value="@titre.Valeur" checked="@titre.Choisie">
        <label class="form-check-label" for="titre-@titre.Valeur">@titre.Libelle</label>
      </div>
    }
  </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="@Model.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>
      @* un champ date échange ses valeurs au format aaaa-mm-jj *@
      <input class="form-control" type="date" id="naissance" name="naissance"
             value="@Model.Saisie.Naissance?.ToString("yyyy-MM-dd")">
    </div>
  </div>

  <div class="mb-2">
    <span class="form-label me-3">Loisirs</span>
    @for (var i = 0; i < Model.Loisirs.Count; i++)
    {
      var loisir = Model.Loisirs[i];
      @* 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-@i"
               value="@loisir.Valeur" checked="@loisir.Choisie">
        <label class="form-check-label" for="loisir-@i">@loisir.Libelle</label>
      </div>
    }
  </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">
        @foreach (var pays in Model.Pays)
        {
          <option value="@pays.Valeur" selected="@pays.Choisie">@pays.Libelle</option>
        }
      </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">
        @foreach (var langue in Model.Langues)
        {
          <option value="@langue.Valeur" selected="@langue.Choisie">@langue.Libelle</option>
        }
      </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">@Model.Saisie.Commentaire</textarea>
  </div>

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

@if (Model.Recu is not null)
{
  <h2 class="h5 mt-3">Ce que le serveur a reçu</h2>
  <pre class="bg-light p-2 border">@Model.Recu</pre>
}

Commentons ce code :

  • ligne 3 : le formulaire est envoyé par POST : ses champs voyagent dans le corps de la requête ;
  • ligne 5 : 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 9-15 : des boutons radio portent le même [name] : un seul peut être coché, et une seule valeur est envoyée. L'attribut booléen [checked="@titre.Choisie"] coche le bouton (cf. [selected], exemple 17) ;
  • ligne 28 : un mot de passe n'est jamais renvoyé dans une page : le champ n'a pas d'attribut [value] ;
  • lignes 33-34 : un champ date. Le navigateur affiche la date dans le format de l'utilisateur, mais envoie et reçoit toujours [aaaa-mm-jj] ;
  • lignes 40-46 : des cases à cocher de même [name] : chaque case cochée envoie sa valeur ; le paramètre est donc répété ;
  • lignes 55-60 : une liste déroulante. L'option choisie porte l'attribut [selected] ;
  • ligne 65 : l'attribut [multiple] permet de choisir plusieurs options ;
  • ligne 77 : la valeur d'une zone de texte est entre ses balises, pas dans un attribut [value] ;
  • lignes 83-86 : ce que le serveur a reçu, affiché dans une balise [<pre>], qui respecte les sauts de ligne.

Le formulaire initial (http://localhost:5000/formulaire), 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 des expressions [@], 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:5000/formulaire
1
2
3
4
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:5000/formulaire
1
2
3
4
jeton = [A7X2]
titre = [Mr]
nom = [Martin]
loisirs = [sport, musique]

(seul le contenu de la balise [<pre>] de la page est reproduit, tel que l'affiche un navigateur). Une case non cochée n'est pas envoyée du tout : [Request.Form] ne contient pas le champ, et la propriété [Loisirs] de la saisie garde sa valeur initiale, le tableau vide.

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, avec [ModelState]) sur un formulaire d'inscription.

Le fichier [Models/InscriptionModele.cs] :

using System.ComponentModel.DataAnnotations;

namespace Exemple19.Models;

// les champs du formulaire et leurs contraintes
public class InscriptionModele
{
    // retire les espaces superflus d'une saisie (le "setter" est appelé par la liaison de modèle)
    private string? nom;

    [Required(ErrorMessage = "Le nom est obligatoire.")]
    [MaxLength(20, ErrorMessage = "Le nom ne doit pas dépasser 20 caractères.")]
    public string? Nom
    {
        get => nom;
        set => nom = value?.Trim();
    }

    [Required(ErrorMessage = "L'adresse électronique est obligatoire.")]
    [EmailAddress(ErrorMessage = "Adresse électronique invalide.")]
    public string? Email { get; set; }

    // tout ce qui vient d'un formulaire est une chaîne : "42" -> 42
    [Required(ErrorMessage = "L'âge est obligatoire.")]
    [Range(18, 120, ErrorMessage = "L'âge doit être compris entre 18 et 120 ans.")]
    public int? Age { get; set; }

    [Required(ErrorMessage = "Le mot de passe est obligatoire.")]
    [MinLength(6, ErrorMessage = "Le mot de passe doit comporter au moins 6 caractères.")]
    public string? MotDePasse { get; set; }

    // une règle qui porte sur DEUX champs : [Compare] compare avec MotDePasse
    [Compare(nameof(MotDePasse), ErrorMessage = "La confirmation ne correspond pas au mot de passe.")]
    public string? Confirmation { get; set; }
}

// le modèle de la vue : les valeurs à réafficher, les erreurs par champ
public class FormulaireInscription
{
    public InscriptionModele Valeurs { get; init; } = new();
    public Dictionary<string, string> Erreurs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
    public bool Html5 { get; init; }
}

// le modèle de la vue partielle _Champ : un champ de saisie
public record ChampModele(string Nom, string Libelle, string Type, string? Valeur, string? Erreur, string Attributs);

Commentons ce code :

  • lignes 9-16 : une propriété avec un champ privé. Son accesseur [set], appelé par la liaison de modèle, retire les espaces superflus de la saisie : [" Martin "] devient ["Martin"]. Un nom fait uniquement d'espaces est, lui, converti en [null] par la liaison de modèle elle-même, et [[Required]] le refuse ;
  • ligne 12 : [[MaxLength]] limite la longueur d'une chaîne, comme [[StringLength]] ;
  • lignes 25-26 : l'âge est converti en entier, puis validé ;
  • ligne 29 : au moins 6 caractères ;
  • ligne 33 : la règle « la confirmation est égale au mot de passe » porte sur deux champs. L'attribut [[Compare]] compare la propriété qu'il décore à une autre propriété, désignée par son nom ([nameof(MotDePasse)] : le nom de la propriété, vérifié par le compilateur) ;
  • lignes 38-42 : le modèle de la vue : les valeurs à réafficher, les erreurs rangées par champ (un dictionnaire dont les clés ignorent la casse), et un booléen expliqué plus loin ;
  • ligne 46 : le modèle de la vue partielle qui affiche un champ.

Le fichier [Controllers/InscriptionController.cs] :

using Exemple19.Models;
using Microsoft.AspNetCore.Mvc;

namespace Exemple19.Controllers;

[Route("inscription")]
public class InscriptionController : Controller
{
    // ?html5=1 : on laisse le navigateur valider le formulaire avant l'envoi
    [HttpGet("")]
    public IActionResult Afficher(bool html5)
    {
        return View("Inscription", new FormulaireInscription { Html5 = html5 });
    }

    [HttpPost("")]
    public IActionResult Inscrire(InscriptionModele saisie, [FromQuery] bool html5)
    {
        if (!ModelState.IsValid)
        {
            // les erreurs par champ, pour afficher chaque message sous son champ
            var erreurs = ModelState
                .Where(c => c.Value!.Errors.Count > 0)
                .ToDictionary(c => c.Key, c => c.Value!.Errors[0].ErrorMessage, StringComparer.OrdinalIgnoreCase);
            // on RÉAFFICHE le formulaire : valeurs saisies (sauf les mots de passe),
            // et un message sous chaque champ erroné
            saisie.MotDePasse = saisie.Confirmation = null;
            ViewData["Erreur"] = "Le formulaire contient des erreurs.";
            return View("Inscription", new FormulaireInscription { Valeurs = saisie, Erreurs = erreurs, Html5 = html5 });
        }
        return View("Bienvenue", saisie);
    }
}

Commentons ce code :

  • lignes 11-13 : le formulaire vide. Le paramètre [html5] sera expliqué plus loin ;
  • ligne 17 : la liaison de modèle remplit [saisie] à partir des champs du formulaire et la valide. [[FromQuery]] va chercher [html5] dans l'URL ;
  • lignes 19-24 : s'il y a des erreurs, on les range par champ. Un champ peut violer plusieurs contraintes : on ne garde que le premier message ([Errors[0]]) ;
  • lignes 27-29 : on réaffiche le formulaire : les valeurs saisies (sauf les mots de passe), les erreurs par champ, et un message global, déposé dans [ViewData] pour le gabarit ;
  • ligne 31 : sinon, la page de bienvenue, avec les valeurs converties (l'âge est un nombre, le nom a été nettoyé).

Une erreur de conversion (un âge [abc]) produit par défaut un message en anglais : [The value 'abc' is not valid for Age.] (cf. exemple 08). Le fichier [Program.cs] le remplace :

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(options =>
{
    // les messages des erreurs de CONVERSION (ex. âge "abc"), en anglais par défaut
    var messages = options.ModelBindingMessageProvider;
    messages.SetAttemptedValueIsInvalidAccessor((valeur, champ) => $"La valeur [{valeur}] n'est pas valide.");
    messages.SetValueMustNotBeNullAccessor(champ => "Ce champ est obligatoire.");
});
// Razor écrit tels quels les caractères accentués (et non "&#xE9;") ;
// < > & " ' restent échappés
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));

var app = builder.Build();

// 1. les fichiers statiques du dossier wwwroot : wwwroot/css/styles.css est servi en /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, partagé par les exemples : le dossier exemples/commun/bootstrap est servi sous /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();
  • lignes 6-11 : les messages des erreurs de liaison de modèle sont fournis par l'objet [ModelBindingMessageProvider] des options de MVC. Chaque méthode [Set...Accessor] remplace l'un d'eux par une fonction qui fabrique le message. [SetAttemptedValueIsInvalidAccessor] concerne une valeur impossible à convertir ; elle reçoit la valeur et le nom du champ. L'étude de cas remplace ainsi tous ces messages.

Les champs sont tous construits sur le même modèle : une vue partielle les factorise. Le fichier [Views/Shared/_Champ.cshtml] :

@* un champ de saisie ; son modèle : nom, libellé, type, valeur, erreur, et les
   attributs de validation HTML5 (required, maxlength...) *@
@model ChampModele
<div class="mb-2">
  <label class="form-label" for="@Model.Nom">@Model.Libelle</label>
  <input class="form-control @(Model.Erreur is null ? "" : "is-invalid")" id="@Model.Nom" name="@Model.Nom"
         type="@Model.Type" value="@Model.Valeur" @Html.Raw(Model.Attributs)>
  @if (Model.Erreur is not null)
  {
    <div class="invalid-feedback">@Model.Erreur</div>
  }
</div>
  • ligne 6 : la classe [Bootstrap] [is-invalid] encadre en rouge un champ erroné ;
  • ligne 7 : les attributs de validation HTML5 sont passés en paramètre et insérés sans échappement ([Html.Raw]) : ils sont écrits par le développeur, pas saisis par l'utilisateur ;
  • ligne 10 : le message d'erreur, sous le champ (classe [Bootstrap] [invalid-feedback]).

Le fichier [Views/Inscription/Inscription.cshtml] :

@model FormulaireInscription
@{
    // l'erreur d'un champ, ou null
    string? Erreur(string champ) => Model.Erreurs.GetValueOrDefault(champ);
}
<h1 class="h3">Inscription</h1>
@if (Model.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=true">l'activer</a></p>
}
@* novalidate : le navigateur ignore les attributs required, min... et
   envoie le formulaire tel quel ; seul le serveur valide *@
<form method="post" action="/inscription@(Model.Html5 ? "?html5=true" : "")" novalidate="@(!Model.Html5)">
  <partial name="_Champ" model='new ChampModele("nom", "Nom", "text", Model.Valeurs.Nom, Erreur("nom"), "required maxlength=\"20\"")' />
  <partial name="_Champ" model='new ChampModele("email", "Adresse électronique", "email", Model.Valeurs.Email, Erreur("email"), "required")' />
  <partial name="_Champ" model='new ChampModele("age", "Âge", "number", Model.Valeurs.Age?.ToString(), Erreur("age"), "required min=\"18\" max=\"120\"")' />
  <partial name="_Champ" model='new ChampModele("motDePasse", "Mot de passe", "password", null, Erreur("motDePasse"), "required minlength=\"6\"")' />
  <partial name="_Champ" model='new ChampModele("confirmation", "Confirmation", "password", null, Erreur("confirmation"), "required")' />
  <button class="btn btn-primary" type="submit">S'inscrire</button>
</form>
  • ligne 4 : une fonction locale [C#], déclarée dans le bloc [@{ }] : elle rend l'erreur d'un champ, ou [null] ;
  • ligne 17 : l'attribut booléen [novalidate] est écrit quand [Html5] est faux ;
  • lignes 18-22 : chaque champ est une insertion de la vue partielle, avec un modèle fabriqué sur place.

Le fichier [Views/Shared/_Layout.cshtml] :

<!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">
    @RenderBody()
    @* l'erreur globale, en bas de page, sur fond rose léger ;
       ViewData["Erreur"] est déposée par l'action *@
    @if (ViewData["Erreur"] is string erreur)
    {
      <div class="zone-erreur" role="alert">@erreur</div>
    }
  </div>
</body>
</html>
  • lignes 14-16 : le message global est affiché en bas de la page, sur un fond rose léger (classe [zone-erreur] de [wwwroot/css/styles.css]), comme dans l'étude de cas. [is string erreur] teste le type de la valeur et la range dans une variable.

Envoyons le formulaire (http://localhost:5000/inscription) avec plusieurs erreurs :

Image

puis correctement rempli (avec des espaces autour du nom) :

Image

Et avec un âge non numérique, envoyé par [curl] (le navigateur l'aurait refusé dans un champ [type="number"]) :

curl -i -d "nom=Martin&email=xx&age=abc" http://localhost:5000/inscription
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<!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">
    <h1 class="h3">Inscription</h1>
  <p>Validation par le navigateur <b>désactivée</b> - <a href="/inscription?html5=true">l'activer</a></p>
<form method="post" action="/inscription" novalidate="novalidate">
  <div class="mb-2">
  <label class="form-label" for="nom">Nom</label>
  <input class="form-control " id="nom" name="nom"
         type="text" value="Martin" required maxlength="20">
</div>

  <div class="mb-2">
  <label class="form-label" for="email">Adresse électronique</label>
  <input class="form-control is-invalid" id="email" name="email"
         type="email" value="xx" required>
    <div class="invalid-feedback">Adresse électronique invalide.</div>
</div>

  <div class="mb-2">
  <label class="form-label" for="age">Âge</label>
  <input class="form-control is-invalid" id="age" name="age"
         type="number" required min="18" max="120">
    <div class="invalid-feedback">La valeur [abc] n&#x27;est pas valide.</div>
</div>

  <div class="mb-2">
  <label class="form-label" for="motDePasse">Mot de passe</label>
  <input class="form-control is-invalid" id="motDePasse" name="motDePasse"
         type="password" required minlength="6">
    <div class="invalid-feedback">Le mot de passe est obligatoire.</div>
</div>

  <div class="mb-2">
  <label class="form-label" for="confirmation">Confirmation</label>
  <input class="form-control " id="confirmation" name="confirmation"
         type="password" required>
</div>

  <button class="btn btn-primary" type="submit">S'inscrire</button>
</form>

      <div class="zone-erreur" role="alert">Le formulaire contient des erreurs.</div>
  </div>
</body>
</html>

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 » (http://localhost:5000/inscription?html5=true), 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 refait qu'un 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. [ASP.NET Core] fournit pour cela [TempData] : un dictionnaire dont le contenu survit jusqu'à la requête suivante, et disparaît dès qu'il a été lu. C'est un message flash. Par défaut, [TempData] est conservé dans un cookie, chiffré par le serveur.

Le fichier [Services/TachesService.cs] :

namespace Exemple20.Services;

// la liste des tâches, en mémoire, partagée par tous (singleton)
public class TachesService
{
    private readonly List<string> taches = ["Lire le chapitre 3"];

    public IReadOnlyList<string> Liste() => taches;

    public void Ajouter(string libelle) => taches.Add(libelle);
}

Le fichier [Controllers/TachesController.cs] :

using Exemple20.Services;
using Microsoft.AspNetCore.Mvc;

namespace Exemple20.Controllers;

[Route("taches")]
public class TachesController(TachesService taches) : Controller
{
    // GET /taches : la liste et le formulaire d'ajout
    [HttpGet("")]
    public IActionResult Afficher()
    {
        return View("Taches", taches.Liste());
    }

    // POST /taches : le schéma Post / Redirect / Get
    [HttpPost("")]
    public IActionResult Ajouter(string? libelle)
    {
        var texte = (libelle ?? "").Trim();
        if (texte == "" || texte.Length > 40)
        {
            // saisie invalide : on réaffiche la page, sans redirection
            ViewData["Libelle"] = libelle;
            ViewData["Erreur"] = "Le libellé doit comporter de 1 à 40 caractères.";
            return View("Taches", taches.Liste());
        }
        taches.Ajouter(texte);
        // TempData : un message qui survivra à la redirection, pour la requête SUIVANTE
        TempData["Flash"] = $"La tâche [{texte}] a été ajoutée.";
        // pas de page : une redirection vers la page GET
        return RedirectToAction(nameof(Afficher));
    }

    // POST /taches/sans-prg : le contre-exemple (une page en réponse au POST)
    [HttpPost("sans-prg")]
    public IActionResult AjouterSansPrg(string? libelle)
    {
        taches.Ajouter(string.IsNullOrWhiteSpace(libelle) ? "(sans titre)" : libelle.Trim());
        ViewData["SansPrg"] = true;
        return View("Taches", taches.Liste());
    }
}

Commentons ce code :

  • lignes 11-13 : la page de la liste ;
  • lignes 21-26 : une saisie invalide : on réaffiche la page (pas de redirection : il faut garder la saisie et le message d'erreur) ;
  • lignes 28-32 : une saisie valide : l'opération, le message flash dans [TempData], puis la redirection (302) vers l'action [Afficher]. [RedirectToAction] calcule l'URL de l'action à partir de sa route (cf. exemple 04). [nameof(Afficher)] est le nom de la méthode, vérifié par le compilateur ;
  • lignes 36-41 : le contre-exemple, pour comparer.

Le fichier [Views/Taches/Taches.cshtml] :

@model IReadOnlyList<string>
<h1 class="h3">Mes tâches</h1>
<ol>
  @foreach (var tache in Model)
  {
    <li>@tache</li>
  }
</ol>
<form method="post" action="/taches" class="d-flex gap-2">
  <input class="form-control" name="libelle" value="@ViewData["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 (ViewData["SansPrg"] is true)
{
  <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>
}
  • ligne 13 : 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/Shared/_Layout.cshtml] :

<!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 : le LIRE l'efface de TempData *@
    @if (TempData["Flash"] is string flash)
    {
      <div class="alert alert-success" role="status">@flash</div>
    }
    @RenderBody()
    @if (ViewData["Erreur"] is string erreur)
    {
      <div class="zone-erreur" role="alert">@erreur</div>
    }
  </div>
</body>
</html>

Le message flash est affiché en haut de la page (lignes 12-14), l'erreur en bas (lignes 17-19). Lire [TempData["Flash"]] marque la valeur comme consommée : elle est effacée à la fin de la requête.

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 ; [--data-urlencode] code les espaces et les accents du champ :

curl -i -c cookies.txt -b cookies.txt --data-urlencode "libelle=Réviser ASP.NET" http://localhost:5000/taches
1
2
3
4
HTTP/1.1 302 Found
Content-Length: 0
Location: /taches
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8B0oe7_4_ZJHlxaqeCPfd4UeD0...; path=/; samesite=lax; httponly

La réponse est une redirection, avec le cookie [.AspNetCore.Mvc.CookieTempDataProvider], qui contient le message flash chiffré (sa valeur, très longue, a été abrégée). 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:5000/taches
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly

<!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">
      <div class="alert alert-success" role="status">La tâche [Réviser ASP.NET] a été ajoutée.</div>
    <h1 class="h3">Mes tâches</h1>
<ol>
    <li>Lire le chapitre 3</li>
    <li>Réviser ASP.NET</li>
</ol>
<form method="post" action="/taches" class="d-flex gap-2">
  <input class="form-control" name="libelle" placeholder="Nouvelle tâche">
  <button class="btn btn-primary text-nowrap" type="submit">Ajouter</button>
  <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="/taches/sans-prg">Ajouter sans PRG</button>
</form>

  </div>
</body>
</html>

Dans le navigateur (http://localhost:5000/taches), 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. Les fichiers de l'exemple :

21-liens-boutons/
├── Controllers/
│   └── ContactsController.cs
├── Services/
│   └── ContactsService.cs
├── Views/
│   ├── Contacts/
│   │   ├── Fiche.cshtml
│   │   └── Liste.cshtml
│   ├── Shared/
│   │   ├── _Confirmation.cshtml
│   │   └── _Layout.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── wwwroot/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── appsettings.json
├── Exemple21.csproj
└── Program.cs

Le fichier [Services/ContactsService.cs] :

namespace Exemple21.Services;

// un contact : un identifiant, un nom, un numéro de téléphone
public record Contact(int Id, string Nom, string Telephone);

// les contacts, en mémoire, partagés par tous (singleton)
public class ContactsService
{
    private readonly List<Contact> contacts =
    [
        new(1, "Marie Durand", "01 23 45 67 89"),
        new(2, "Paul Martin", "06 12 34 56 78"),
        new(3, "Philippe Jandot", "02 98 76 54 32"),
    ];
    private int dernierId = 3;

    public IReadOnlyList<Contact> Liste() => contacts;

    // null si le contact n'existe pas
    public Contact? Lire(int id) => contacts.Find(c => c.Id == id);

    public Contact? Modifier(int id, string nom, string telephone)
    {
        var index = contacts.FindIndex(c => c.Id == id);
        if (index < 0) return null;
        // un record est immuable : on le remplace par une copie modifiée (with)
        contacts[index] = contacts[index] with { Nom = nom, Telephone = telephone };
        return contacts[index];
    }

    public Contact Ajouter(string nom, string telephone)
    {
        var contact = new Contact(++dernierId, nom, telephone);
        contacts.Add(contact);
        return contact;
    }

    public Contact? Supprimer(int id)
    {
        var contact = Lire(id);
        if (contact is not null) contacts.Remove(contact);
        return contact;
    }
}
  • ligne 4 : un contact ;
  • ligne 20 : [Find] rend [null] si aucun contact n'a cet identifiant ;
  • lignes 22-28 : un [record] est immuable : on ne modifie pas ses propriétés, on le remplace par une copie modifiée. L'expression [with { ... }] fabrique cette copie ;
  • lignes 31-35 : [++dernierId] incrémente le compteur avant de l'utiliser : les identifiants sont 4, 5...

Le fichier [Controllers/ContactsController.cs] :

using Exemple21.Services;
using Microsoft.AspNetCore.Mvc;

namespace Exemple21.Controllers;

[Route("contacts")]
public class ContactsController(ContactsService contacts) : Controller
{
    // un LIEN (<a href>) : une requête GET, qui ne modifie rien
    [HttpGet("")]
    public IActionResult Liste()
    {
        return View(contacts.Liste());
    }

    [HttpGet("{id:int}")]
    public IActionResult Fiche(int id)
    {
        var contact = contacts.Lire(id);
        return contact is null ? NotFound() : View(contact);
    }

    // un BOUTON dans un formulaire POST : une requête qui modifie des données
    [HttpPost("{id:int}")]
    public IActionResult Enregistrer(int id, string nom, string telephone)
    {
        var contact = contacts.Modifier(id, nom, telephone);
        if (contact is null) return NotFound();
        TempData["Flash"] = $"Le contact [{contact.Nom}] a été modifié.";
        return RedirectToAction(nameof(Liste));
    }

    // même formulaire, autre bouton (attribut formaction) : autre action
    [HttpPost("{id:int}/dupliquer")]
    public IActionResult Dupliquer(int id, string nom, string telephone)
    {
        var copie = contacts.Ajouter($"{nom} (copie)", telephone);
        TempData["Flash"] = $"Le contact [{copie.Nom}] a été créé.";
        return RedirectToAction(nameof(Liste));
    }

    [HttpPost("{id:int}/supprimer")]
    public IActionResult Supprimer(int id)
    {
        var contact = contacts.Supprimer(id);
        if (contact is null) return NotFound();
        TempData["Flash"] = $"Le contact [{contact.Nom}] a été supprimé.";
        return RedirectToAction(nameof(Liste));
    }
}

Commentons ce code :

  • lignes 11-20 : les deux pages : la liste et la fiche d'un contact. La contrainte de route [:int] (cf. exemple 02) n'accepte qu'un identifiant entier ; un contact inexistant provoque une 404 ;
  • lignes 24-30 : l'enregistrement d'une fiche, en PRG ;
  • lignes 34-39 : la duplication, déclenchée par un second bouton du même formulaire (attribut [formaction]) ;
  • lignes 42-48 : la suppression, par un POST.

Le fichier [Views/Contacts/Liste.cshtml] :

@model IReadOnlyList<Contact>
<h1 class="h3">Contacts</h1>
<table class="table align-middle">
  <tbody>
    @foreach (var contact in Model)
    {
      <tr>
        <td>@contact.Nom</td>
        <td>@contact.Telephone</td>
        <td class="text-end">
          @* consulter : un simple lien *@
          <a class="btn btn-sm btn-outline-primary" href="/contacts/@contact.Id">Voir</a>
          @* supprimer : un formulaire POST, dont l'envoi sera confirmé *@
          <form method="post" action="/contacts/@contact.Id/supprimer" class="d-inline"
                data-confirmation="Supprimer le contact @contact.Nom ?">
            <button class="btn btn-sm btn-outline-danger" type="submit">Supprimer</button>
          </form>
        </td>
      </tr>
    }
  </tbody>
</table>
  • ligne 12 : consulter : un lien, présenté comme un bouton par [Bootstrap] ;
  • lignes 14-17 : 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/Contacts/Fiche.cshtml] :

@model Contact
<h1 class="h3">Contact n° @Model.Id</h1>
<form method="post" action="/contacts/@Model.Id">
  <div class="mb-2">
    <label class="form-label" for="nom">Nom</label>
    <input class="form-control" id="nom" name="nom" value="@Model.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="@Model.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/@Model.Id/dupliquer">Dupliquer</button>
  <a class="btn btn-link" href="/contacts">Retour à la liste</a>
</form>
  • lignes 13-14 : 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/Shared/_Confirmation.cshtml] :

@* 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].

Le fichier [Views/Shared/_Layout.cshtml] :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Contacts</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: 40rem">
    @if (TempData["Flash"] is string flash)
    {
      <div class="alert alert-success" role="status">@flash</div>
    }
    @RenderBody()
  </div>
  @* la fenêtre de confirmation, présente dans toutes les pages *@
  <partial name="_Confirmation" />
  @* le script, en fin de page : les éléments qu'il cherche existent déjà *@
  <script src="/js/app.js"></script>
</body>
</html>
  • ligne 18 : la fenêtre est présente dans toutes les pages ;
  • ligne 20 : le script est chargé en fin de page : quand il s'exécute, les éléments qu'il cherche existent déjà. Il est servi par [UseStaticFiles] depuis [wwwroot/js/app.js].

Un peu de [JavaScript] fait le lien entre les formulaires et la fenêtre. Le fichier [wwwroot/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-14 : 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-25 : « 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 [wwwroot/js/app.js] ajoute la gestion du clavier : touche Échap, focus). Les pages obtenues, à partir de http://localhost:5000/contacts : la liste, la demande de confirmation, la liste après suppression, la fiche d'un contact :

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:5000/contacts/2/supprimer
1
2
3
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Allow: POST

Le chemin existe, mais pas pour la méthode GET : [ASP.NET Core] répond [405 Method Not Allowed], et l'entête [Allow] indique la méthode acceptée. Avec POST :

curl -i -X POST http://localhost:5000/contacts/2/supprimer
1
2
3
4
HTTP/1.1 302 Found
Content-Length: 0
Location: /contacts
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8B0oe7_4_ZJHlxaqeCPfd4UHFy...; path=/; samesite=lax; httponly