Skip to content

4. The View and Its Model

Up to this point, actions have generated their own responses. In a MVC application, this is the role of a view: a file that describes the page HTML, with placeholders for the data passed to it by the action—the view’s template. [ASP.NET Core MVC] uses the [Razor] view engine: A view is a [.cshtml] file, derived from HTML, into which [C#] code is inserted, preceded by the character [@]. [Razor] is part of the framework: there is nothing to install.

4.1. Example 13: An Initial View

The [Program.cs] file:

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

// the MVC services: controllers AND Razor views
builder.Services.AddControllersWithViews();

var app = builder.Build();
app.MapControllers();
app.Run();
  • Line 4: [AddControllersWithViews] replaces [AddControllers] from the previous examples. It adds view services to the controller services: the [Razor] engine, view file search, and Tag Helpers (Example 15).

The view template is a simple class. The file [Models/PageModel.cs]:

1
2
3
4
namespace Example13.Models;

// the view model: the data the action passes to the view
public record PageModel(string Title, string Message, string Time);
  • line 4: a [record] is a class whose properties are set by the constructor; here, three strings: [Title], [Message], and [Time]. This is a concise way to define a class that simply carries data.

The file [Controllers/ViewsController.cs]:

using Example13.Models;
using Microsoft.AspNetCore.Mvc;

namespace Example13.Controllers;

public class ViewsController : Controller
{
    // View(model): the view that bears the name of the action (Views/Views/Index.cshtml)
    [HttpGet("/")]
    public IActionResult Index()
    {
        var model = new PageModel(
            "My first view",
            "This page was built by Razor, on the server.",
            DateTime.Now.ToString("HH:mm:ss"));
        return View(model);
    }

    // View(name, model): the view is chosen by the action
    [HttpGet("hello/{name}")]
    public IActionResult Hello(string name)
    {
        if (name == "unknown")
        {
            Response.StatusCode = 404;
            return View("Unknown", name);
        }
        var model = new PageModel(
            $"Hello {name}",
            "This time, the action designated the view by its name.",
            DateTime.Now.ToString("HH:mm:ss"));
        return View("Index", model);
    }
}

Let’s comment on this code:

  • Line 6: The controller derives from class [Controller], rather than [ControllerBase]: [Controller] adds to [ControllerBase] everything related to views (methods [View], [ViewData], [TempData], etc.);
  • lines 12–15: the action generates the view template: a title, a message, and the current time;
  • line 16: [View(model)] returns a [ViewResult]: the action no longer generates the response; instead, it designates a view and passes a template to it. If no view name is specified, the view with the same name as the action is used: [Index];
  • lines 23–26: When the view depends on the request, the action selects the view. Here, the name [unknown] displays the view [Unknown], with status code 404. The template for this view is a simple string;
  • line 32: the action [Hello] explicitly specifies the view [Index] by name: two actions share the same view, with different templates.

Where does [ASP.NET Core] look for the view [Index] for action [ViewsController.Index]? By convention, in two folders, in this order:

  • [Views/Views/Index.cshtml]: the folder named after the controller (without the suffix [Controller]);
  • [Views/Shared/Index.cshtml]: the folder containing views shared by all controllers (see Example 15).

If the view is not found anywhere, an exception lists the paths that were checked. The project files:

13-first-view/
├── Controllers/
│   └── ViewsController.cs
├── Models/
│   └── PageModel.cs
├── Views/
│   ├── Views/
│   │   ├── Index.cshtml
│   │   └── Unknown.cshtml
│   └── _ViewImports.cshtml
├── appsettings.json
├── Example13.csproj
└── Program.cs

The file [Views/_ViewImports.cshtml]:

@* the "using" directives shared by all the views of the Views folder *@
@using Example13.Models
  • Line 1: [@* ... *@] is a comment [Razor]: it does not appear on the generated page (unlike a comment HTML [<!-- -->]);
  • Line 2: The directives in the [_ViewImports.cshtml] file apply to all views in its folder and subfolders. [@using Example13.Models] allows you to write [PageModel] instead of [Example13.Models.PageModel] in the views.

The [Views/Views/Index.cshtml] file:

@* the type of the model of this view *@
@model PageModel
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>@Model.Title</title>
</head>
<body>
  <h1>@Model.Title</h1>
  <p>@Model.Message</p>
  <p>Page built at @Model.Time.</p>
</body>
</html>
  • line 2: the directive [@model] declares the type of the view template. In the view, the property [Model] is then of type [PageModel], and the compiler verifies that [Model.Title] exists;
  • Lines 7–12: Each expression [@Model.Title] is replaced by the corresponding value from the template. [Razor] recognizes the end of an expression on its own: in [@Model.Time.], the period is followed by a tag, so it is not a property access.

A view is a [C#] file in disguise: when the project is compiled ([dotnet build]), each [.cshtml] file is translated into a [C#] class, which is compiled along with the rest of the application. An error in a view ([@Model.Titl]) is therefore reported during compilation. The result, in the URL http://localhost:5000/:

Image

The source code of the page received by the browser no longer contains any trace of [Razor]:

curl http://localhost:5000/
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>My first view</title>
</head>
<body>
  <h1>My first view</h1>
  <p>This page was built by Razor, on the server.</p>
  <p>Page built at 12:12:36.</p>
</body>
</html>

Note that accented characters inserted by an [@] expression would be encoded (this page contains none): a French title such as [Ma première vue] would be sent as [Ma premi&#xE8;re vue], [&#xE8;] being the code for the character [è], whereas accented text typed directly into the view would remain unchanged. The browser displays both correctly, but the code HTML is difficult to read. We’ll see in Example 15 how to avoid this. The URL http://localhost:5000/hello/martin displays the same view using a different template:

Image

and http://localhost:5000/hello/unknown displays a different view:

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

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [unknown] here.</h1>
</body>
</html>

The file [Views/Views/Unknown.cshtml]:

@* the model of this view is a simple string: the requested name *@
@model string
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [@Model] here.</h1>
</body>
</html>
  • Line 2: The template for this view is a string;
  • line 10: [@Model] is the string itself.

4.2. Example 14: The Razor language

[Razor] is not a “logic-free” template language like [Handlebars]: you can write [C#] code within a view (conditions, loops, variables, method calls). This freedom requires discipline: the view must remain purely for formatting; calculations and decisions belong to the action and the services. Example 14 provides an overview of the syntax. The template, file [Models/BasicsModel.cs]:

namespace Example14.Models;

public record Product(string Name, decimal Price, bool InStock);

public record Address(string Street, string City);

public class BasicsModel
{
    public string Title { get; set; } = "";
    public string Name { get; set; } = "";
    public string? Nickname { get; set; }
    public string Html { get; set; } = "";
    public bool LoggedIn { get; set; }
    public string Currency { get; set; } = "";
    public List<Product> Products { get; set; } = [];
    public List<Product> Cart { get; set; } = [];
    public Address Address { get; set; } = new("", "");
}

The file [Controllers/BasicsController.cs]:

using Example14.Models;
using Microsoft.AspNetCore.Mvc;

namespace Example14.Controllers;

public class BasicsController : Controller
{
    [HttpGet("/")]
    public IActionResult Index()
    {
        var model = new BasicsModel
        {
            Title = "Razor basics",
            Name = "Martin",
            Nickname = null,
            Html = "<b>this is HTML</b>",
            LoggedIn = true,
            Currency = "€",
            Products =
            [
                new Product("Keyboard", 25m, true),
                new Product("Mouse", 12.5m, false),
                new Product("Screen", 149m, true),
            ],
            Cart = [],
            Address = new Address("12 rue des Lilas", "Angers"),
        };
        return View(model);
    }
}
  • Lines 11–26: The action populates the template: a few strings, a Boolean, a list of three products, an empty shopping cart, and an address. [Nickname] is intentionally the same as [null].

The view, file [Views/Basics/Index.cshtml]:

@model BasicsModel
@* a C# code block: it produces nothing in the page *@
@{
    var total = Model.Products.Sum(p => p.Price);
    var count = Model.Products.Count;
}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>@Model.Title</title>
</head>
<body>
  <h1>@Model.Title</h1>
  @* 1. display a value of the model; a null value displays nothing *@
  <p>1. Name: @Model.Name - nickname: [@Model.Nickname]</p>
  @* 2. @ escapes HTML, @Html.Raw inserts it as is *@
  <p>2a. @Model.Html</p>
  <p>2b. @Html.Raw(Model.Html)</p>
  @* 3. a condition *@
  @if (Model.LoggedIn)
  {
    <p>3. You are logged in.</p>
  }
  else
  {
    <p>3. You are not logged in.</p>
  }
  @* 4. a loop; the loop variables are C# variables *@
  <p>4. Our products:</p>
  <ul>
    @for (var i = 0; i < Model.Products.Count; i++)
    {
      var product = Model.Products[i];
      <li>no. @i: @product.Name, @product.Price @Model.Currency
        @(product.InStock ? "(in stock)" : "(sold out)")
        @if (i == 0) { <text>- the first one</text> }
        @if (i == Model.Products.Count - 1) { @:- the last one, seen by @Model.Name
        }
      </li>
    }
  </ul>
  @* 5. the empty list *@
  <p>5. Your cart:
    @if (Model.Cart.Count == 0)
    {
      <text>it is empty.</text>
    }
    else
    {
      foreach (var p in Model.Cart) { <span>@p.Name</span> }
    }
  </p>
  @* 6. an explicit expression @( ... ), and the variables of the @{ } block *@
  <p>6. @count products, for a total of @(total) @Model.Currency; average price: @((total / count).ToString("0.00")) @Model.Currency</p>
  <p>7. Address: @Model.Address.Street, @Model.Address.City.ToUpper()</p>
  <p>8. An email address: contact@example.com</p>
</body>
</html>

Let’s comment on this code:

  • Lines 2–5: A block of code named [@{ ... }] contains instructions named [C#]. It does not produce anything on the page. The variables it declares ([total], [count]) can be used later in the view;
  • line 16: a value [null] ([@Model.Nickname]) displays nothing, with no error;
  • Lines 18–19: An expression [@...] escapes the special characters in HTML ([<] becomes [&lt;]...): the text is displayed as-is. [@Html.Raw(...)] inserts it without escaping: it is interpreted as HTML. [Html.Raw] is used only for HTML generated by the application, never for data entered by a user: the user could inject HTML, or even [JavaScript], into the page (an attack known as [XSS], [cross-site scripting]);
  • Lines 21–27: a condition. After [@if (...)], the content within the curly braces is HTML: [Razor] recognizes the start of a tag and reverts to HTML mode. We return to [C#] via [@];
  • lines 32–40: a loop from [for] to [C#]. [var product = ...] is an instruction [C#], [<li>] of HTML: [Razor] alternates between the two without any specific marker;
  • Line 36: an explicit expression [@( ... )]. Parentheses are required whenever the expression contains spaces or operators (in this case, the conditional operator [? :]);
  • line 37: To write unformatted text within a [C#] block, enclose it in the special tag [<text>], which does not appear on the page;
  • line 38: [@:] does the same thing for the rest of the line;
  • lines 45–51: An empty list is simply [if]. In the [else] block, already in [C#] mode, [foreach] is written without [@];
  • line 55: the variables of the [@{ }] block and explicit expressions, including a method call: [ToString("0.00")] formats a number with two decimal places;
  • line 56: a pointed path [@Model.Address.Street], and a method call [ToUpper()];
  • line 57: [Razor] recognizes that a [@] placed between two alphanumeric characters is not code: [contact@example.com] is an email address. To write a [@] that would be ambiguous, we duplicate it: [@@].

The result, URL, is http://localhost:5000/:

Image

The code HTML produces:

curl http://localhost:5000/
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Razor basics</title>
</head>
<body>
  <h1>Razor basics</h1>
  <p>1. Name: Martin - nickname: []</p>
  <p>2a. &lt;b&gt;this is HTML&lt;/b&gt;</p>
  <p>2b. <b>this is HTML</b></p>
    <p>3. You are logged in.</p>
  <p>4. Our products:</p>
  <ul>
      <li>no. 0: Keyboard, 25 &#x20AC;
        (in stock)
- the first one      </li>
      <li>no. 1: Mouse, 12.5 &#x20AC;
        (sold out)
      </li>
      <li>no. 2: Screen, 149 &#x20AC;
        (in stock)
 - the last one, seen by Martin
      </li>
  </ul>
  <p>5. Your cart:
it is empty.  </p>
  <p>6. 3 products, for a total of 186.5 &#x20AC;; average price: 62.17 &#x20AC;</p>
  <p>7. Address: 12 rue des Lilas, ANGERS</p>
  <p>8. An email address: contact@example.com</p>
</body>
</html>

Note the spaces and blank lines left by the [C#] instructions: they have no effect on the display. We also see the encoding of accented characters derived from expressions ([&#x20AC;] for [€]), which is corrected in the following example.

4.3. Example 15: Template, Partial Views, Static Files

The pages of a website share a common template (header, navigation bar, footer). [Razor] refers to this template as a “layout”: each view is inserted into it. Reusable page components are partial views. Finally, pages require static files (style sheets, images, scripts), which are served as-is, without going through a controller. The files in the example:

15-layout/
├── Controllers/
│   └── PagesController.cs
├── Views/
│   ├── Pages/
│   │   ├── Home.cshtml
│   │   ├── Raw.cshtml
│   │   └── Team.cshtml
│   ├── Shared/
│   │   ├── _Card.cshtml
│   │   ├── _Layout.cshtml
│   │   └── _Navbar.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── wwwroot/
│   └── css/
│       └── styles.css
├── appsettings.json
├── Example15.csproj
└── Program.cs

The file [Program.cs]:

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

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Razor writes accented characters as is (and not "&#xE9;");
// < > & " ' remain escaped
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));

var app = builder.Build();

// 1. the static files of the wwwroot folder: wwwroot/css/styles.css is served as /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, shared by the examples: the examples/common/bootstrap folder is served under /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "common", "bootstrap"))),
    RequestPath = "/bootstrap",
});

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

Let’s comment on this code:

  • line 9: the [HtmlEncoder] service is the one used by [Razor] to escape the expressions in [@]. By default, it only allows characters from the basic Latin alphabet to pass through: this is what encodes [é] as [&#xE9;] (and [€] as [&#x20AC;]). We replace this service with an encoder that allows all characters to pass through ([UnicodeRanges.All]). The dangerous characters ([<], [>], [&], ["], [']) remain escaped: the protection against injection in HTML remains intact. All of the following examples, as well as the case study, include this line;
  • line 14: a middleware component (see Chapter 7) that serves files from the [wwwroot] folder of the project: the file [wwwroot/css/styles.css] is served to URL and [/css/styles.css]. If the file does not exist, the request continues on to the controllers;
  • Lines 16–19: A second static file service. The CSS [Bootstrap] framework is common to all examples: it is stored only once, in the [examples/common/bootstrap] folder, and served under the prefix [/bootstrap]. [PhysicalFileProvider] refers to a folder on the disk; [ContentRootPath] is the project folder. Thus, [examples/common/bootstrap/css/bootstrap.min.css] is served to URL and [/bootstrap/css/bootstrap.min.css]. The case study, on the other hand, places [Bootstrap] in its own folder, [wwwroot].

Let’s check:

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

/* a few settings on top of Bootstrap */
body {
  background-color: #f5f7fa;
}

/* the footer of the layout */
.footer {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* the error messages, at the bottom of the page, on a light pink background */
.error-area {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

The file [Views/_ViewStart.cshtml]:

1
2
3
4
@* executed before every view: all the views use the _Layout layout *@
@{
    Layout = "_Layout";
}
  • Line 3: The file [_ViewStart.cshtml] is executed before each view in its folder and subfolders. It sets the [Layout] property: all views use the [_Layout] template, which is searched for in the view’s folder and then in [Views/Shared].

The file [Views/_ViewImports.cshtml]:

1
2
3
@* the ASP.NET Core Tag Helpers: "enriched" HTML tags, such as <partial> *@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using Example15.Controllers
  • Line 2: Enables the Tag Helpers provided by [ASP.NET Core]: HTML tags or attributes processed by the server. The [<partial>] tag is one such example. [*] means “all Tag Helpers in the assembly” [Microsoft.AspNetCore.Mvc.TagHelpers];
  • Line 3: The type [Member] is declared in the controllers namespace.

The file [Views/Shared/_Layout.cshtml]:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  @* ViewData["Title"]: a value set by the view, read by the layout *@
  <title>@ViewData["Title"] - My 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" />
    @* the view requested by the action is inserted here *@
    @RenderBody()
    @* Context: the current request (HttpContext), available to every view *@
    <footer class="footer">Page [@Context.Request.Path] - © @DateTime.Now.Year</footer>
  </div>
</body>
</html>
  • line 7: [ViewData] is a dictionary shared by a view and its template (and by the action, which can also store values in it). The view stores its title there, and the template displays it;
  • lines 8–9: the style sheets, served by [UseStaticFiles];
  • line 13: insertion of the partial view [_Navbar], searched for in the view’s folder and then in [Views/Shared];
  • line 15: the location where [Razor] inserts the view requested by the action;
  • line 17: [Context] is the [HttpContext] object of the current request (see Example 05), accessible to any view: the template displays the requested path without each action having to pass it along.

The file [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">My site</span>
  <div class="navbar-nav">
    <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/team">Team</a>
    <a class="nav-link" href="/raw">Page without layout</a>
  </div>
</nav>

The file [Views/Shared/_Card.cshtml]:

1
2
3
4
5
6
7
8
@* a Bootstrap "card"; its model: a Member (name, role) *@
@model Member
<div class="card">
  <div class="card-body">
    <h5 class="card-title">@Model.Name</h5>
    <p class="card-text">@Model.Role</p>
  </div>
</div>

By convention, the name of a partial view or template begins with [_]: these are not complete pages. The partial view [_Card] has its own template (line 2), a name, and a role. The type [Member] is declared in the controller. The file [Controllers/PagesController.cs]:

using Microsoft.AspNetCore.Mvc;

namespace Example15.Controllers;

public record Member(string Name, string Role);

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

    [HttpGet("team")]
    public IActionResult Team()
    {
        List<Member> members =
        [
            new Member("Marie Pélissier", "Director"),
            new Member("Jacques Bromard", "Developer"),
            new Member("Philippe Jandot", "Graphic designer"),
        ];
        return View(members);
    }

    [HttpGet("raw")]
    public IActionResult Raw()
    {
        return View();
    }
}
  • line 5: the type of team members;
  • line 12: the action [Home] has no template: [View()] with no arguments;
  • lines 18–24: the action [Team] passes a list of members.

The partial view is used in two ways. The file [Views/Pages/Home.cshtml]:

1
2
3
4
5
6
7
@{
    ViewData["Title"] = "Home";
}
<h1>Welcome</h1>
<p>This page only contains its own content: the header, the navigation
  bar and the footer come from the <code>_Layout.cshtml</code> layout.</p>
<partial name="_Card" model='new Member("A partial view", "inserted with a model built on the spot")' />
  • line 2: the view places its title in [ViewData], for the template;
  • Line 7: The [model] attribute of the [<partial>] tag provides the template for the partial view: in this case, an object created on the fly. This is a [C#] expression: it is enclosed in apostrophes so that quotation marks can be used within it.

The file [Views/Pages/Team.cshtml]:

@model List<Member>
@{
    ViewData["Title"] = "Our team";
}
<h1>Our team</h1>
<div class="row g-3">
  @foreach (var member in Model)
  {
    <div class="col-md-4">
      @* the partial view receives the current member as its model *@
      <partial name="_Card" model="member" />
    </div>
  }
</div>
  • Line 11: The partial view receives the current loop iteration as its template.

The file [Views/Pages/Raw.cshtml]:

@* Layout = null: this view does not use the layout, it is a complete page *@
@{
    Layout = null;
}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Raw page</title>
</head>
<body>
  <p>This page does not use the layout: no Bootstrap, no navigation bar.</p>
  <p><a href="/">Back to home</a></p>
</body>
</html>
  • Line 3: Setting [Layout] to [null] in a view cancels the selection of [_ViewStart.cshtml]: The view [Raw] is a complete HTML page. You could also specify a different template ([Layout = "_Other"]).

The resulting pages, at URL http://localhost:5000/, http://localhost:5000/team, and http://localhost:5000/raw:

Image

Image

Image

The template, partial views, and [ViewData] are exactly what the case study uses: a [_Layout.cshtml] template, partial views for the navigation bar, messages, and confirmation windows.

4.4. Example 16: Helpers

A view often requires small formatting functions: formatting a date or a price, capitalizing text, creating a badge HTML... With [Razor], these are simple methods [C#], called within an expression [@]. [ASP.NET Core] also offers Tag Helpers: [C#] code that acts on a HTML tag when the page is rendered.

The [Helpers/Formats.cs] file:

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

namespace Example16.Helpers;

// ordinary C# functions, callable from the views: @Formats.Money(...)
public static class Formats
{
    // "keyboard" -> "KEYBOARD"
    public static string Uppercase(string? text) => (text ?? "").ToUpperInvariant();

    // an amount in a currency, in the format of the current language (CultureInfo.CurrentCulture)
    // "C" = currency; the NumberFormatInfo object sets the currency symbol
    public static string Money(decimal amount, string currency = "EUR")
    {
        var format = (NumberFormatInfo)CultureInfo.CurrentCulture.NumberFormat.Clone();
        format.CurrencySymbol = currency == "USD" ? "$" : "€";
        return amount.ToString("C", format);
    }

    // a date in full words, in the current language: "Wednesday, September 23, 2026"
    public static string Date(DateTime when) => when.ToString("D", CultureInfo.CurrentCulture);

    // a helper that builds HTML: it returns an IHtmlContent, which Razor does not escape.
    // It is then up to the helper to escape the data it inserts (HtmlEncoder)
    public static IHtmlContent Badge(int stock)
    {
        var cssClass = stock > 0 ? "text-bg-success" : "text-bg-danger";
        var text = HtmlEncoder.Default.Encode(stock > 0 ? $"{stock} in stock" : "sold out");
        return new HtmlString($"<span class=\"badge {cssClass}\">{text}</span>");
    }
}

Let’s break down this code:

  • line 8: a static class—its methods are called without creating an object, [Formats.Uppercase(...)];
  • line 11: capitalization. [ToUpperInvariant] is independent of the user’s language;
  • lines 15–19: an amount in a currency. The format ["C"] ([currency]) formats an amount according to the current locale ([CultureInfo.CurrentCulture]): decimal separator, thousands separator, and currency symbol position. The locale rules are copied ([Clone]) to change the currency symbol;
  • line 23: a date written out in full (format ["D"]), in the language of the current culture;
  • lines 27–31: a helper that generates HTML. The output of a [@] expression is escaped, unless it is a [IHtmlContent]: pre-generated HTML. [HtmlString] is the simplest implementation of this. The helper must then escape the data it inserts into its HTML ([HtmlEncoder.Default.Encode]) itself.

The [TagHelpers/ActiveTagHelper.cs] file:

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

namespace Example16.TagHelpers;

// a Tag Helper: it applies to the <a> tags that carry the "active-path" attribute
// <a href="/products" active-path="/products"> gets the "active" class if the
// current page is under this path
[HtmlTargetElement("a", Attributes = "active-path")]
public class ActiveTagHelper(IHttpContextAccessor accessor) : TagHelper
{
    // the value of the active-path="..." attribute
    public string ActivePath { get; set; } = "";

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var path = accessor.HttpContext?.Request.Path.Value ?? "/";
        var isActive = ActivePath == "/" ? path == "/" : path.StartsWith(ActivePath, StringComparison.Ordinal);
        if (isActive)
        {
            output.AddClass("active", HtmlEncoder.Default); // adds the class to the class attribute
        }
    }
}

Let’s break down this code:

  • line 10: The Tag Helper applies to [<a>] tags that have the [active-path] attribute;
  • line 11: a Tag Helper derives from the [TagHelper] class. It is a service: it receives a [IHttpContextAccessor] via injection, which provides access to the current request;
  • line 14: the value of the [active-path="..."] attribute is assigned to the property of the same name ([ActivePath]);
  • Lines 16–22: The method [Process] is called for each relevant tag. If the page path begins with the value of the attribute, the class CSS [active] is added to the tag ([AddClass]). The [active-path] attribute itself does not appear in the generated page: the Tag Helper has consumed it.

The file [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: gives access to the current request (used by ActiveTagHelper)
builder.Services.AddHttpContextAccessor();

var app = builder.Build();

// a middleware: the formatting language comes from the ?lang=fr parameter of the URL
app.Use(async (context, next) =>
{
    var language = context.Request.Query["lang"] == "fr" ? "fr-FR" : "en-US";
    CultureInfo.CurrentCulture = new CultureInfo(language);
    await next(context); // hand over to the next middleware
});

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

app.MapControllers();
app.Run();
  • line 10: registers the [IHttpContextAccessor] service, used by the Tag Helper;
  • lines 15–19: middleware written directly into [Program.cs], executed for each request. It selects the culture based on the [lang] parameter from URL, then passes control to the next middleware ([next]). This is a simple way to test; the next chapter will cover it better.

The [Views/_ViewImports.cshtml] file:

1
2
3
4
5
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@* our own Tag Helpers, those of the Example16 assembly *@
@addTagHelper *, Example16
@using Example16.Helpers
@using Example16.Controllers
  • Line 3: We enable the Tag Helpers for our own project (the [Example16] assembly);
  • line 4: views can write [Formats.Money(...)].

The [Views/Shared/_Navbar.cshtml] file:

<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Helpers</span>
  <div class="navbar-nav">
    @* 1. a condition written in the view *@
    <a class="nav-link @(Context.Request.Path == "/" ? "active" : "")" href="/">Home</a>
    @* 2. our Tag Helper: the active-path="..." attribute *@
    <a class="nav-link" href="/products" active-path="/products">Products</a>
    @* 3. an ASP.NET Tag Helper: asp-action builds the URL of the action *@
    <a class="nav-link" asp-controller="Pages" asp-action="Products" asp-route-lang="fr">Produits (fr)</a>
  </div>
</nav>
  • Lines 4–5: First way to highlight the current page’s link: a condition written in the view;
  • line 7: second, more concise method: our Tag Helper. This is the one used in the case study;
  • line 9: a Tag Helper for [ASP.NET Core]. The attributes [asp-controller] and [asp-action] denote an action; the Tag Helper calculates its URL based on the routes (here, [/products]) and writes it to the [href] attribute. [asp-route-lang="fr"] adds a parameter, which is placed in URL ([?lang=fr]) since the route does not contain a [lang] parameter. If the action’s URL is changed, the link follows suit.

The code HTML in the navigation bar, from URL to [/products]:

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

The file [Views/Pages/Products.cshtml]:

@model List<Product>
@{
    ViewData["Title"] = "Products";
}
<h1>Products</h1>
<table class="table table-sm w-auto">
  <thead><tr><th>Product</th><th>Price</th><th>Price in dollars</th><th>Stock</th></tr></thead>
  <tbody>
    @foreach (var product in Model)
    {
      <tr>
        <td>@Formats.Uppercase(product.Name)</td>
        <td>@Formats.Money(product.Price)</td>
        <td>@Formats.Money(product.Price, "USD")</td>
        <td>@Formats.Badge(product.Stock)</td>
      </tr>
    }
  </tbody>
</table>
  • Lines 12–15: The helpers are called within [@] expressions. [Badge] returns HTML, inserted without escaping.

The resulting pages, in English (http://localhost:5000/, http://localhost:5000/products) and then in French (http://localhost:5000/products?lang=fr):

Image

Image

Image

The prices change format depending on the language: [1 149,90 €] in French, [€1,149.90] in English.

4.5. Example 17: a GET form

A HTML form submitted using the GET method places its fields in the URL. This is the appropriate method for a search: it does not modify anything, and the resulting URL can be bookmarked or shared. The case study uses it to select a doctor and a date.

The [Models/Book.cs] file:

1
2
3
4
5
6
namespace Example17.Models;

public record Book(string Title, string Author, string Genre);

// the view model: the input (to display it again), the genres, the results
public record SearchModel(string Keyword, string Genre, string[] Genres, List<Book> Results);
  • Line 6: The view template contains the entered values ([Keyword], [Genre]), so that the form can display them again, along with the list of genres and the results.

The file [Controllers/BooksController.cs]:

using Example17.Models;
using Microsoft.AspNetCore.Mvc;

namespace Example17.Controllers;

public class BooksController : Controller
{
    private static readonly List<Book> Books =
    [
        new Book("Le Rouge et le Noir", "Stendhal", "novel"),
        new Book("Les Fleurs du mal", "Baudelaire", "poetry"),
        new Book("Madame Bovary", "Flaubert", "novel"),
        new Book("Alcools", "Apollinaire", "poetry"),
        new Book("Le Cid", "Corneille", "drama"),
    ];

    private static readonly string[] Genres = ["novel", "poetry", "drama"];

    // GET / and GET /search?keyword=...&genre=...: the form AND its results
    [HttpGet("/")]
    [HttpGet("search")]
    public IActionResult Search(string? keyword, string? genre)
    {
        var word = (keyword ?? "").Trim().ToLower();
        var results = Books
            .Where(b => word == "" || b.Title.ToLower().Contains(word) || b.Author.ToLower().Contains(word))
            .Where(b => string.IsNullOrEmpty(genre) || b.Genre == genre)
            .ToList();
        return View(new SearchModel(keyword ?? "", genre ?? "", Genres, results));
    }
}
  • lines 8–14: the books, in memory;
  • lines 20–21: two route attributes: the action processes [GET /] and [GET /search];
  • Line 22: The parameters [keyword] and [genre] are linked to the parameters of the same name in URL. On the first visit, the form has not yet been submitted: their values are [null];
  • lines 25–28: filtering, using [LINQ]. An empty keyword or an empty category does not filter anything;
  • line 29: the view template.

The file [Views/Books/Search.cshtml]:

@model SearchModel
<h1 class="h3">Book search</h1>
@* method="get": the fields are sent in the URL (?keyword=...&genre=...) *@
<form method="get" action="/search" class="row g-2 align-items-end mb-3">
  <div class="col-auto">
    <label class="form-label" for="keyword">Title or author</label>
    @* value="@Model.Keyword": the field displays again what was typed *@
    <input class="form-control" id="keyword" name="keyword" value="@Model.Keyword">
  </div>
  <div class="col-auto">
    <label class="form-label" for="genre">Genre</label>
    <select class="form-select" id="genre" name="genre">
      <option value="">(all)</option>
      @foreach (var genre in Model.Genres)
      {
        @* a boolean attribute: written if the condition is true, omitted otherwise *@
        <option value="@genre" selected="@(genre == Model.Genre)">@genre</option>
      }
    </select>
  </div>
  <div class="col-auto">
    <button class="btn btn-primary" type="submit">Search</button>
  </div>
</form>
<p>@Model.Results.Count book(s) found.</p>
<ul>
  @foreach (var book in Model.Results)
  {
    <li><i>@book.Title</i>, @book.Author (@book.Genre)</li>
  }
</ul>
  • line 4: [method="get"] and [action="/search"]: submitting the form is a [GET /search?keyword=...&genre=...] request. The parameter names are the [name] attribute of each field;
  • Line 8: The [value] attribute re-displays the entry;
  • line 17: In the drop-down list, the option corresponding to the selected genre is assigned the attribute [selected]. [Razor] handles Boolean attributes in a specific way: if the value is [true], it writes [selected="selected"]; if it is [false], it does not write the attribute at all. This is exactly what HTML requires, for which the mere presence of the attribute [selected] is sufficient to select the option.

On the first display (http://localhost:5000/), and then after a search:

Image

Image

The address bar shows the parameters that were sent. Accented characters and spaces are encoded there ([%C3%A9]...): this is the URL encoding, which [ASP.NET Core] decodes before the template binding.

4.6. Example 18: Form Fields

Example 18 includes all types of HTML fields: text, password, date, radio buttons, checkboxes, drop-down list, multi-select list, text box, and hidden field. It shows how the server receives them and how the view displays them.

The [Services/ListsService.cs] file:

namespace Example18.Services;

public record Country(string Code, string Name);

// the lists offered by the form. In a real application, they would
// come from a database: we would read them ONCE, at startup,
// since they are the same for all users
public class ListsService
{
    public string[] Titles { get; } = ["Mr", "Mme", "Melle"];

    public Country[] Countries { get; } =
    [
        new Country("fr", "France"),
        new Country("be", "Belgium"),
        new Country("ch", "Switzerland"),
        new Country("ca", "Canada"),
    ];

    public string[] Hobbies { get; } = ["reading", "music", "sport", "cinema"];

    public string[] Languages { get; } = ["French", "English", "Spanish", "German"];
}

The lists offered by the form are provided by a service. They are the same for all users: a service instantiated only once (a singleton; see Chapter 6) is the natural place to store them. It is saved in [Program.cs]:

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

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Razor writes accented characters as is (and not "&#xE9;");
// < > & " ' remain escaped
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// the lists of the form: the same for everyone, a single object (singleton)
builder.Services.AddSingleton<ListsService>();

var app = builder.Build();

// 1. the static files of the wwwroot folder: wwwroot/css/styles.css is served as /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, shared by the examples: the examples/common/bootstrap folder is served under /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "common", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();
  • line 12: a single instance of [ListsService], shared by all requests.

The file [Models/Input.cs]:

namespace Example18.Models;

// what the user typed: model binding fills each property
// with the field of the same name (the arrays receive all the checked values)
public class Input
{
    public string? Name { get; set; }
    public string? Password { get; set; }
    public string? Comment { get; set; }
    public string? Title { get; set; }
    public string[] Hobbies { get; set; } = [];
    public string? Country { get; set; }
    public string[] Languages { get; set; } = [];
    public DateOnly? BirthDate { get; set; }
    public string? Token { get; set; }
}

// an option of a list, and its state (checked / selected)
public record Option(string Value, string Label, bool Chosen);

// the view model
public class FormModel
{
    public required Input Input { get; init; }
    public required List<Option> Titles { get; init; }
    public required List<Option> Hobbies { get; init; }
    public required List<Option> Countries { get; init; }
    public required List<Option> Languages { get; init; }
    public string? Received { get; init; } // what the server received, to display it
}

Let’s comment on this code:

  • Lines 5–15: the input. The template binding populates each property with the field of the same name. The checkboxes and the drop-down list are arrays ([string[]]): they receive all the values sent (see Example 10). The date of birth is converted to [DateOnly];
  • line 19: an option from a list: its value, its label, and a Boolean indicating whether it is checked or selected;
  • lines 22–29: the view template. The keyword [required] requires that the property be provided when the object is created; [init] prevents it from being modified afterward.

The [Controllers/FormController.cs] file:

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

namespace Example18.Controllers;

[Route("form")]
public class FormController(ListsService lists) : Controller
{
    // GET: the form, with initial values
    [HttpGet("")]
    public IActionResult Display()
    {
        var initial = new Input { Title = "Mme", Country = "fr", Languages = ["French"], Token = "A7X2" };
        return View("Form", Model(initial, null));
    }

    // POST: model binding fills [input]; we display the form again
    [HttpPost("")]
    public IActionResult Process(Input input)
    {
        // what the browser actually sent, field by field
        var received = string.Join("\n", Request.Form.Select(c => $"{c.Key} = [{string.Join(", ", c.Value.ToArray())}]"));
        return View("Form", Model(input, received));
    }

    // the view model: the lists, each option being marked checked /
    // selected according to the input. The view will only have to test a boolean.
    private FormModel Model(Input input, string? received) => new()
    {
        Input = input,
        Titles = lists.Titles.Select(t => new Option(t, t, t == input.Title)).ToList(),
        Hobbies = lists.Hobbies.Select(h => new Option(h, h, input.Hobbies.Contains(h))).ToList(),
        Countries = lists.Countries.Select(c => new Option(c.Code, c.Name, c.Code == input.Country)).ToList(),
        Languages = lists.Languages.Select(l => new Option(l, l, input.Languages.Contains(l))).ToList(),
        Received = received,
    };
}

Let’s comment on this code:

  • line 8: the list service is injected;
  • lines 11–15: [GET /form] displays the form with initial values;
  • lines 19–24: [POST /form] receives the user input—populated by the model binding—and redraws the form. [Request.Form] also provides access to the fields as they were submitted: it converts them into text, one line per field, for display;
  • lines 29–36: the view template. For each list, the action calculates which options should be checked or selected: the view will then only need to test a Boolean.

The file [Views/Form/Form.cshtml]:

@model FormModel
<h1 class="h3">A complete form</h1>
<form method="post" action="/form" style="max-width: 40rem">
  @* hidden field: sent with the form, but invisible *@
  <input type="hidden" name="token" value="@Model.Input.Token">

  <div class="mb-2">
    <span class="form-label me-3">Title</span>
    @foreach (var title in Model.Titles)
    {
      @* radio buttons: same [name], a single value sent *@
      <div class="form-check form-check-inline">
        <input class="form-check-input" type="radio" name="title" id="title-@title.Value"
               value="@title.Value" checked="@title.Chosen">
        <label class="form-check-label" for="title-@title.Value">@title.Label</label>
      </div>
    }
  </div>

  <div class="row g-2 mb-2">
    <div class="col">
      <label class="form-label" for="name">Name</label>
      <input class="form-control" type="text" id="name" name="name" value="@Model.Input.Name">
    </div>
    <div class="col">
      <label class="form-label" for="password">Password</label>
      @* a password is NEVER sent back in the page *@
      <input class="form-control" type="password" id="password" name="password">
    </div>
    <div class="col">
      <label class="form-label" for="birthDate">Date of birth</label>
      @* a date field exchanges its values in the yyyy-mm-dd format *@
      <input class="form-control" type="date" id="birthDate" name="birthDate"
             value="@Model.Input.BirthDate?.ToString("yyyy-MM-dd")">
    </div>
  </div>

  <div class="mb-2">
    <span class="form-label me-3">Hobbies</span>
    @for (var i = 0; i < Model.Hobbies.Count; i++)
    {
      var hobby = Model.Hobbies[i];
      @* checkboxes: same [name], several possible values *@
      <div class="form-check form-check-inline">
        <input class="form-check-input" type="checkbox" name="hobbies" id="hobby-@i"
               value="@hobby.Value" checked="@hobby.Chosen">
        <label class="form-check-label" for="hobby-@i">@hobby.Label</label>
      </div>
    }
  </div>

  <div class="row g-2 mb-2">
    <div class="col">
      <label class="form-label" for="country">Country</label>
      <select class="form-select" id="country" name="country">
        @foreach (var country in Model.Countries)
        {
          <option value="@country.Value" selected="@country.Chosen">@country.Label</option>
        }
      </select>
    </div>
    <div class="col">
      <label class="form-label" for="languages">Spoken languages</label>
      @* multiple-selection list: several possible values *@
      <select class="form-select" id="languages" name="languages" multiple size="4">
        @foreach (var language in Model.Languages)
        {
          <option value="@language.Value" selected="@language.Chosen">@language.Label</option>
        }
      </select>
    </div>
  </div>

  <div class="mb-2">
    <label class="form-label" for="comment">Comment</label>
    @* a text area: the value is BETWEEN the tags *@
    <textarea class="form-control" id="comment" name="comment" rows="2">@Model.Input.Comment</textarea>
  </div>

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

@if (Model.Received is not null)
{
  <h2 class="h5 mt-3">What the server received</h2>
  <pre class="bg-light p-2 border">@Model.Received</pre>
}

Let’s comment on this code:

  • Line 3: The form is submitted via POST: its fields are included in the request body;
  • Line 5: A hidden field is sent with the form but is not displayed. It is used to pass information from one page to the next (in the case study, for example, it contains the version of a record). Note: The user can modify it;
  • Lines 9–15: Radio buttons share the same [name]; only one can be selected, and only one value is sent. The Boolean attribute [checked="@title.Chosen"] selects the button (see [selected], Example 17);
  • line 28: a password is never sent back in a page: the field has no [value] attribute;
  • lines 33–34: a date field. The browser displays the date in the user’s format, but always sends and receives [yyyy-mm-dd];
  • lines 40–46: checkboxes with the same [name] attribute: each checked box sends its value; the parameter is therefore repeated;
  • lines 55–60: a drop-down list. The selected option has the attribute [selected];
  • line 65: the [multiple] attribute allows you to select multiple options;
  • line 77: the value of a text field is enclosed within its tags, not in a [value] attribute;
  • Lines 83–86: What the server received, displayed within a [<pre>] tag, which preserves line breaks.

The initial form (http://localhost:5000/form), then after submission:

Image

Image

Note that the text field re-displayed [Hello <b>everyone</b>] exactly as entered: thanks to the escaping of [@] expressions, the entered tags are not interpreted. The password was received but is not displayed. With [curl], we can see the difference when a checkbox is selected:

curl -d "token=A7X2&title=Mr&name=Martin&hobbies=sport" http://localhost:5000/form
1
2
3
4
token = [A7X2]
title = [Mr]
name = [Martin]
hobbies = [sport]

and two checked boxes:

curl -d "token=A7X2&title=Mr&name=Martin&hobbies=sport&hobbies=music" http://localhost:5000/form
1
2
3
4
token = [A7X2]
title = [Mr]
name = [Martin]
hobbies = [sport, music]

(only the content of the [<pre>] tag on the page is reproduced, as displayed by a browser). An unchecked box is not sent at all: [Request.Form] does not contain the field, and the [Hobbies] property of the input retains its initial value—the empty array.

4.7. Example 19: Form Validation

An invalid form must be redisplayed, showing the entered values and a message below each field with an error. Example 19 applies the technique from Example 09 (validation performed by the action, using [ModelState]) to a registration form.

The [Models/RegistrationModel.cs] file:

using System.ComponentModel.DataAnnotations;

namespace Example19.Models;

// the form fields and their constraints
public class RegistrationModel
{
    // removes the superfluous spaces of an input (the "setter" is called by model binding)
    private string? name;

    [Required(ErrorMessage = "The name is required.")]
    [MaxLength(20, ErrorMessage = "The name must not exceed 20 characters.")]
    public string? Name
    {
        get => name;
        set => name = value?.Trim();
    }

    [Required(ErrorMessage = "The email address is required.")]
    [EmailAddress(ErrorMessage = "Invalid email address.")]
    public string? Email { get; set; }

    // everything that comes from a form is a string: "42" -> 42
    [Required(ErrorMessage = "The age is required.")]
    [Range(18, 120, ErrorMessage = "The age must be between 18 and 120 years.")]
    public int? Age { get; set; }

    [Required(ErrorMessage = "The password is required.")]
    [MinLength(6, ErrorMessage = "The password must have at least 6 characters.")]
    public string? Password { get; set; }

    // a rule about TWO fields: [Compare] compares with Password
    [Compare(nameof(Password), ErrorMessage = "The confirmation does not match the password.")]
    public string? Confirmation { get; set; }
}

// the view model: the values to display again, the errors per field
public class RegistrationForm
{
    public RegistrationModel Values { get; init; } = new();
    public Dictionary<string, string> Errors { get; init; } = new(StringComparer.OrdinalIgnoreCase);
    public bool Html5 { get; init; }
}

// the model of the _Field partial view: an input field
public record FieldModel(string Name, string Label, string Type, string? Value, string? Error, string Attributes);

Let’s comment on this code:

  • Lines 9–16: a property with a private field. Its accessor, [set]—called by the model binding—removes unnecessary spaces from the input: [" Martin "] becomes ["Martin"]. A name consisting solely of spaces is converted to [null] by the model binding itself, and [[Required]] is rejected;
  • line 12: [[MaxLength]] limits the length of a string, just like [[StringLength]];
  • Lines 25–26: The age is converted to an integer and then validated;
  • line 29: at least 6 characters;
  • line 33: the rule “the confirmation matches the password” applies to two fields. The [[Compare]] attribute compares the property it decorates to another property, designated by its name ([nameof(Password)]: the property name, verified by the compiler);
  • lines 38–42: the view template: the values to be redisplayed, errors sorted by field (a dictionary whose keys are case-insensitive), and a Boolean explained further on;
  • line 46: the partial view template that displays a field.

The file [Controllers/RegistrationController.cs]:

using Example19.Models;
using Microsoft.AspNetCore.Mvc;

namespace Example19.Controllers;

[Route("register")]
public class RegistrationController : Controller
{
    // ?html5=1: we let the browser validate the form before sending it
    [HttpGet("")]
    public IActionResult Display(bool html5)
    {
        return View("Registration", new RegistrationForm { Html5 = html5 });
    }

    [HttpPost("")]
    public IActionResult Register(RegistrationModel input, [FromQuery] bool html5)
    {
        if (!ModelState.IsValid)
        {
            // the errors per field, to display each message under its field
            var errors = ModelState
                .Where(c => c.Value!.Errors.Count > 0)
                .ToDictionary(c => c.Key, c => c.Value!.Errors[0].ErrorMessage, StringComparer.OrdinalIgnoreCase);
            // we display the form AGAIN: typed values (except the passwords),
            // and a message under each invalid field
            input.Password = input.Confirmation = null;
            ViewData["Error"] = "The form contains errors.";
            return View("Registration", new RegistrationForm { Values = input, Errors = errors, Html5 = html5 });
        }
        return View("Welcome", input);
    }
}

Let’s comment on this code:

  • Lines 11–13: the empty form. The parameter [html5] will be explained later;
  • Line 17: The template link populates [input] using the form fields and validates it. [[FromQuery]] retrieves [html5] from URL;
  • Lines 19–24: If there are errors, they are organized by field. A field may violate multiple constraints: only the first error message ([Errors[0]]) is retained;
  • lines 27–29: The form is redisplayed: the entered values (except passwords), errors by field, and a general message, stored in [ViewData] for the template;
  • line 31: otherwise, the welcome page, with the converted values (age is a number, the name has been cleaned up).

A conversion error (an age [abc]) generates an English message by default: [The value 'abc' is not valid for Age.] (see example 08). The file [Program.cs] replaces it:

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

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(options =>
{
    // the messages of the CONVERSION errors (e.g. age "abc"), replacing the default ones
    var messages = options.ModelBindingMessageProvider;
    messages.SetAttemptedValueIsInvalidAccessor((value, field) => $"The value [{value}] is not valid.");
    messages.SetValueMustNotBeNullAccessor(field => "This field is required.");
});
// Razor writes accented characters as is (and not "&#xE9;");
// < > & " ' remain escaped
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));

var app = builder.Build();

// 1. the static files of the wwwroot folder: wwwroot/css/styles.css is served as /css/styles.css
app.UseStaticFiles();
// 2. Bootstrap, shared by the examples: the examples/common/bootstrap folder is served under /bootstrap
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "common", "bootstrap"))),
    RequestPath = "/bootstrap",
});

app.MapControllers();
app.Run();
  • Lines 6–11: The messages for template-linking errors are provided by the [ModelBindingMessageProvider] object from the options of MVC. Each method in [Set...Accessor] replaces one of these with a function that generates the message. [SetAttemptedValueIsInvalidAccessor] handles a value that cannot be converted; it receives the value and the field name. The case study thus replaces all of these messages.

The fields are all built using the same model: a partial view factors them out. The file [Views/Shared/_Field.cshtml]:

@* an input field; its model: name, label, type, value, error, and the
   HTML5 validation attributes (required, maxlength...) *@
@model FieldModel
<div class="mb-2">
  <label class="form-label" for="@Model.Name">@Model.Label</label>
  <input class="form-control @(Model.Error is null ? "" : "is-invalid")" id="@Model.Name" name="@Model.Name"
         type="@Model.Type" value="@Model.Value" @Html.Raw(Model.Attributes)>
  @if (Model.Error is not null)
  {
    <div class="invalid-feedback">@Model.Error</div>
  }
</div>
  • line 6: the class [Bootstrap] [is-invalid] highlights an invalid field in red;
  • line 7: the validation attributes HTML5 are passed as parameters and inserted without escaping ([Html.Raw]): they are written by the developer, not entered by the user;
  • line 10: the error message, below the field (class [Bootstrap] [invalid-feedback]).

The file [Views/Registration/Registration.cshtml]:

@model RegistrationForm
@{
    // the error of a field, or null
    string? Error(string field) => Model.Errors.GetValueOrDefault(field);
}
<h1 class="h3">Registration</h1>
@if (Model.Html5)
{
  <p>Browser validation <b>enabled</b> - <a href="/register">disable it</a></p>
}
else
{
  <p>Browser validation <b>disabled</b> - <a href="/register?html5=true">enable it</a></p>
}
@* novalidate: the browser ignores the required, min... attributes and
   sends the form as is; only the server validates *@
<form method="post" action="/register@(Model.Html5 ? "?html5=true" : "")" novalidate="@(!Model.Html5)">
  <partial name="_Field" model='new FieldModel("name", "Name", "text", Model.Values.Name, Error("name"), "required maxlength=\"20\"")' />
  <partial name="_Field" model='new FieldModel("email", "Email address", "email", Model.Values.Email, Error("email"), "required")' />
  <partial name="_Field" model='new FieldModel("age", "Age", "number", Model.Values.Age?.ToString(), Error("age"), "required min=\"18\" max=\"120\"")' />
  <partial name="_Field" model='new FieldModel("password", "Password", "password", null, Error("password"), "required minlength=\"6\"")' />
  <partial name="_Field" model='new FieldModel("confirmation", "Confirmation", "password", null, Error("confirmation"), "required")' />
  <button class="btn btn-primary" type="submit">Register</button>
</form>
  • line 4: a local function [C#], declared in the block [@{ }]: it returns the error for a field, or [null];
  • line 17: the Boolean attribute [novalidate] is set when [Html5] is false;
  • Lines 18–22: Each field is an insertion from the partial view, using a template generated on the fly.

The file [Views/Shared/_Layout.cshtml]:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration</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()
    @* the global error, at the bottom of the page, on a light pink background;
       ViewData["Error"] is set by the action *@
    @if (ViewData["Error"] is string error)
    {
      <div class="error-area" role="alert">@error</div>
    }
  </div>
</body>
</html>
  • lines 14–16: the global message is displayed at the bottom of the page on a light pink background (class [error-area] derived from [wwwroot/css/styles.css]), as in the case study. [is string error] checks the value's type and stores it in a variable.

Let’s submit the form (http://localhost:5000/register) with several errors:

Image

then correctly filled out (with spaces around the name):

Image

And with a non-numeric age, submitted via [curl] (the browser would have rejected it in a [type="number"] field):

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

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration</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">Registration</h1>
  <p>Browser validation <b>disabled</b> - <a href="/register?html5=true">enable it</a></p>
<form method="post" action="/register" novalidate="novalidate">
  <div class="mb-2">
  <label class="form-label" for="name">Name</label>
  <input class="form-control " id="name" name="name"
         type="text" value="Martin" required maxlength="20">
</div>

  <div class="mb-2">
  <label class="form-label" for="email">Email address</label>
  <input class="form-control is-invalid" id="email" name="email"
         type="email" value="xx" required>
    <div class="invalid-feedback">Invalid email address.</div>
</div>

  <div class="mb-2">
  <label class="form-label" for="age">Age</label>
  <input class="form-control is-invalid" id="age" name="age"
         type="number" required min="18" max="120">
    <div class="invalid-feedback">The value [abc] is not valid.</div>
</div>

  <div class="mb-2">
  <label class="form-label" for="password">Password</label>
  <input class="form-control is-invalid" id="password" name="password"
         type="password" required minlength="6">
    <div class="invalid-feedback">The password is required.</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">Register</button>
</form>

      <div class="error-area" role="alert">The form contains errors.</div>
  </div>
</body>
</html>

4.7.1. Validation by the browser

The attributes [required], [maxlength], [min], [max], [type="email"]... instruct the browser to validate the form before submitting it: the user is immediately notified via a tooltip, without any round-trip communication with the server. This is the behavior achieved by clicking the “enable it” link (http://localhost:5000/register?html5=true), which removes the [novalidate] attribute from the form.

This validation is a convenience, not a security measure: you can bypass it simply by removing the [required] attribute using the browser’s developer tools, or by sending the request with [curl]. Server-side validation remains essential. That’s why the example disables browser validation by default: to demonstrate how the server works. The case study, however, retains both: the HTML5 attribute for convenience, and server-side validation for security.

4.8. Example 20: The Post/Redirect/Get Pattern

When a POST form submits successfully, the action can display a page directly. The problem arises if the user then refreshes that page (F5 key): the browser resubmits the form, and the operation is performed a second time (an order placed twice, an appointment made twice...). The Post / Redirect / Get pattern (PRG) prevents this problem: a successful POST action does not generate a page; instead, it redirects to a GET page. Reloading this page simply triggers another GET, with no effect.

One detail remains: the GET page must display a message (“the task has been added”), even though this message was generated by the POST action in a different request. To address this, [ASP.NET Core] provides [TempData]: a dictionary whose contents persist until the next request and disappear as soon as they are read. It is a flash message. By default, [TempData] is stored in a cookie, encrypted by the server.

The file [Services/TasksService.cs]:

namespace Example20.Services;

// the task list, in memory, shared by everyone (singleton)
public class TasksService
{
    private readonly List<string> tasks = ["Read chapter 3"];

    public IReadOnlyList<string> List() => tasks;

    public void Add(string label) => tasks.Add(label);
}

The [Controllers/TasksController.cs] file:

using Example20.Services;
using Microsoft.AspNetCore.Mvc;

namespace Example20.Controllers;

[Route("tasks")]
public class TasksController(TasksService tasks) : Controller
{
    // GET /tasks: the list and the add form
    [HttpGet("")]
    public IActionResult Display()
    {
        return View("Tasks", tasks.List());
    }

    // POST /tasks: the Post / Redirect / Get pattern
    [HttpPost("")]
    public IActionResult Add(string? label)
    {
        var text = (label ?? "").Trim();
        if (text == "" || text.Length > 40)
        {
            // invalid input: we display the page again, without a redirect
            ViewData["Label"] = label;
            ViewData["Error"] = "The label must have 1 to 40 characters.";
            return View("Tasks", tasks.List());
        }
        tasks.Add(text);
        // TempData: a message that will survive the redirect, for the NEXT request
        TempData["Flash"] = $"The task [{text}] has been added.";
        // no page: a redirect to the GET page
        return RedirectToAction(nameof(Display));
    }

    // POST /tasks/without-prg: the counter-example (a page in response to the POST)
    [HttpPost("without-prg")]
    public IActionResult AddWithoutPrg(string? label)
    {
        tasks.Add(string.IsNullOrWhiteSpace(label) ? "(untitled)" : label.Trim());
        ViewData["WithoutPrg"] = true;
        return View("Tasks", tasks.List());
    }
}

Let’s break down this code:

  • lines 11–13: the list page;
  • lines 21–26: an invalid entry: the page is reloaded (no redirection—the entry and error message must be retained);
  • lines 28–32: a valid input: the operation, the message flashes in [TempData], then a 302 redirect to the [Display] action. [RedirectToAction] calculates the URL for the action based on its route (see Example 04). [nameof(Display)] is the method name, which is validated by the compiler;
  • lines 36–41: the counterexample, for comparison.

The file [Views/Tasks/Tasks.cshtml]:

@model IReadOnlyList<string>
<h1 class="h3">My tasks</h1>
<ol>
  @foreach (var task in Model)
  {
    <li>@task</li>
  }
</ol>
<form method="post" action="/tasks" class="d-flex gap-2">
  <input class="form-control" name="label" value="@ViewData["Label"]" placeholder="New task">
  <button class="btn btn-primary text-nowrap" type="submit">Add</button>
  @* formaction: this button sends the SAME form to another address *@
  <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="/tasks/without-prg">Add without PRG</button>
</form>
@if (ViewData["WithoutPrg"] is true)
{
  <p class="text-danger mt-2">Page displayed in response to a POST: press F5 and the task will be added a second time.</p>
}
  • line 13: the [formaction] attribute of a button submits the form to a different address than the one specified by the form’s [action] attribute. The form thus has two buttons, which trigger two different actions. The case study uses this approach on its login page.

The file [Views/Shared/_Layout.cshtml]:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Tasks</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">
    @* the flash message, at the top of the page: READING it removes it from TempData *@
    @if (TempData["Flash"] is string flash)
    {
      <div class="alert alert-success" role="status">@flash</div>
    }
    @RenderBody()
    @if (ViewData["Error"] is string error)
    {
      <div class="error-area" role="alert">@error</div>
    }
  </div>
</body>
</html>

The flash message is displayed at the top of the page (lines 12–14), and the error message at the bottom (lines 17–19). Reading [TempData["Flash"]] marks the value as consumed: it is cleared at the end of the request.

Here is what [curl] sees when the form is successfully submitted. The [-c file] option saves the received cookies, [-b file] sends them back; [--data-urlencode] encodes spaces and accents in the field:

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

The response is a redirect, with the cookie [.AspNetCore.Mvc.CookieTempDataProvider], which contains the encrypted flash message (its value, which is very long, has been truncated). The next request sends the cookie; the response deletes it (expiration date in the past):

curl -i -c cookies.txt -b cookies.txt http://localhost:5000/tasks
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="en">
<head>
  <meta charset="utf-8">
  <title>Tasks</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">The task [Review ASP.NET] has been added.</div>
    <h1 class="h3">My tasks</h1>
<ol>
    <li>Read chapter 3</li>
    <li>Review ASP.NET</li>
</ol>
<form method="post" action="/tasks" class="d-flex gap-2">
  <input class="form-control" name="label" placeholder="New task">
  <button class="btn btn-primary text-nowrap" type="submit">Add</button>
  <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="/tasks/without-prg">Add without PRG</button>
</form>

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

In the browser (http://localhost:5000/tasks), after adding a task:

Image

The address bar displays [/tasks]: this is the page GET. You can safely reload it; the message disappears. An empty entry:

Image

And the counterexample:

Image

Here, reloading the page triggers a browser warning (“Confirm form submission”), then adds the task a second time. All POST forms in the case study follow the PRG pattern.

A simple rule guides the choice between a link and a button:

  • a link ([<a href>]) generates a GET request. A GET request must not modify anything: browsers, search engines, and antivirus programs may follow links on their own to prepare or analyze a page;
  • an operation that modifies data (add, edit, delete, log out) is a button in a form POST.

Example 21 manages a list of contacts: you can view a contact by clicking a link and delete it by clicking a button, which opens a confirmation window. The files for this example:

21-links-buttons/
├── Controllers/
│   └── ContactsController.cs
├── Services/
│   └── ContactsService.cs
├── Views/
│   ├── Contacts/
│   │   ├── Details.cshtml
│   │   └── List.cshtml
│   ├── Shared/
│   │   ├── _Confirmation.cshtml
│   │   └── _Layout.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── wwwroot/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── appsettings.json
├── Example21.csproj
└── Program.cs

The [Services/ContactsService.cs] file:

namespace Example21.Services;

// a contact: an identifier, a name, a phone number
public record Contact(int Id, string Name, string Phone);

// the contacts, in memory, shared by everyone (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 lastId = 3;

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

    // null if the contact does not exist
    public Contact? Read(int id) => contacts.Find(c => c.Id == id);

    public Contact? Edit(int id, string name, string phone)
    {
        var index = contacts.FindIndex(c => c.Id == id);
        if (index < 0) return null;
        // a record is immutable: we replace it with a modified copy (with)
        contacts[index] = contacts[index] with { Name = name, Phone = phone };
        return contacts[index];
    }

    public Contact Add(string name, string phone)
    {
        var contact = new Contact(++lastId, name, phone);
        contacts.Add(contact);
        return contact;
    }

    public Contact? Delete(int id)
    {
        var contact = Read(id);
        if (contact is not null) contacts.Remove(contact);
        return contact;
    }
}
  • Line 4: a contact;
  • line 20: [Find] returns [null] if no contact has that ID;
  • lines 22–28: a [record] is immutable: its properties are not modified; instead, it is replaced with a modified copy. The expression [with { ... }] creates this copy;
  • lines 31–35: [++lastId] increments the counter before using it: the identifiers are 4, 5...

The file [Controllers/ContactsController.cs]:

using Example21.Services;
using Microsoft.AspNetCore.Mvc;

namespace Example21.Controllers;

[Route("contacts")]
public class ContactsController(ContactsService contacts) : Controller
{
    // a LINK (<a href>): a GET request, which modifies nothing
    [HttpGet("")]
    public IActionResult List()
    {
        return View(contacts.List());
    }

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

    // a BUTTON in a POST form: a request that modifies data
    [HttpPost("{id:int}")]
    public IActionResult Save(int id, string name, string phone)
    {
        var contact = contacts.Edit(id, name, phone);
        if (contact is null) return NotFound();
        TempData["Flash"] = $"The contact [{contact.Name}] has been edited.";
        return RedirectToAction(nameof(List));
    }

    // same form, another button (formaction attribute): another action
    [HttpPost("{id:int}/duplicate")]
    public IActionResult Duplicate(int id, string name, string phone)
    {
        var copy = contacts.Add($"{name} (copy)", phone);
        TempData["Flash"] = $"The contact [{copy.Name}] has been created.";
        return RedirectToAction(nameof(List));
    }

    [HttpPost("{id:int}/delete")]
    public IActionResult Delete(int id)
    {
        var contact = contacts.Delete(id);
        if (contact is null) return NotFound();
        TempData["Flash"] = $"The contact [{contact.Name}] has been deleted.";
        return RedirectToAction(nameof(List));
    }
}

Let’s comment on this code:

  • lines 11–20: the two pages: the list and the contact detail page. The route constraint [:int] (see Example 02) only accepts an integer identifier; a nonexistent contact results in a 404 error;
  • lines 24–30: saving a record, using PRG;
  • lines 34–39: duplication, triggered by a second button on the same form (attribute [formaction]);
  • lines 42–48: deletion, using POST.

The file [Views/Contacts/List.cshtml]:

@model IReadOnlyList<Contact>
<h1 class="h3">Contacts</h1>
<table class="table align-middle">
  <tbody>
    @foreach (var contact in Model)
    {
      <tr>
        <td>@contact.Name</td>
        <td>@contact.Phone</td>
        <td class="text-end">
          @* view: a simple link *@
          <a class="btn btn-sm btn-outline-primary" href="/contacts/@contact.Id">View</a>
          @* delete: a POST form, whose submission will be confirmed *@
          <form method="post" action="/contacts/@contact.Id/delete" class="d-inline"
                data-confirmation="Delete the contact @contact.Name?">
            <button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
          </form>
        </td>
      </tr>
    }
  </tbody>
</table>
  • line 12: view: a link, displayed as a button by [Bootstrap];
  • lines 14–17: delete: a small form (POST), reduced to its button. The [data-confirmation] attribute contains the question to be asked before submission. The [data-*] attributes are optional: HTML ignores them, while [JavaScript] on the page can read them.

The [Views/Contacts/Details.cshtml] file:

@model Contact
<h1 class="h3">Contact no. @Model.Id</h1>
<form method="post" action="/contacts/@Model.Id">
  <div class="mb-2">
    <label class="form-label" for="name">Name</label>
    <input class="form-control" id="name" name="name" value="@Model.Name">
  </div>
  <div class="mb-3">
    <label class="form-label" for="phone">Phone</label>
    <input class="form-control" id="phone" name="phone" value="@Model.Phone">
  </div>
  @* two submit buttons for the same form *@
  <button class="btn btn-primary" type="submit">Save</button>
  <button class="btn btn-outline-primary" type="submit" formaction="/contacts/@Model.Id/duplicate">Duplicate</button>
  <a class="btn btn-link" href="/contacts">Back to the list</a>
</form>
  • Lines 13–14: two submit buttons for the same form.

4.9.1. The confirmation window

The browser dialog box ([window.confirm]) cannot be formatted or translated: its buttons remain in the browser’s language. It is replaced by a [Bootstrap] window, embedded in the page and hidden until needed. The [Views/Shared/_Confirmation.cshtml] file:

@* a single window for the whole page, hidden (d-none) at first *@
<div id="confirmation-modal" 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="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="no">No, go back</button>
          <button type="button" class="btn btn-danger" data-action="yes">Yes, confirm</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • line 2: the [d-none] class hides the window;
  • line 3: the gray background;
  • line 11: the question’s position;
  • lines 14–15: the two buttons, identified by the [data-action] attribute.

The file [Views/Shared/_Layout.cshtml]:

<!doctype html>
<html lang="en">
<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>
  @* the confirmation window, present in every page *@
  <partial name="_Confirmation" />
  @* the script, at the end of the page: the elements it looks for already exist *@
  <script src="/js/app.js"></script>
</body>
</html>
  • line 18: the window is present on all pages;
  • line 20: the script is loaded at the bottom of the page; when it runs, the elements it looks for already exist. It is served by [UseStaticFiles] from [wwwroot/js/app.js].

A small portion of [JavaScript] links the forms to the window. The file [wwwroot/js/app.js]:

// confirmation of the forms that carry the data-confirmation="..." attribute
// Without JavaScript, these forms are simply sent without confirmation.
(() => {
  const modal = document.getElementById('confirmation-modal');
  const message = document.getElementById('confirmation-modal-message');
  let pending = null; // the form to send if the user confirms

  // 1. we intercept the submission of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault(); // the submission is suspended
      pending = form;
      message.textContent = form.dataset.confirmation;
      modal.classList.remove('d-none'); // the window appears
    });
  });

  // 2. the two buttons of the window
  modal.addEventListener('click', (event) => {
    const action = event.target.dataset.action;
    if (action === 'yes') {
      pending.submit(); // submission, WITHOUT firing the 'submit' event again
    } else if (action === 'no') {
      modal.classList.add('d-none');
      pending = null;
    }
  });
})();
  • line 3: the code is wrapped in a function that executes immediately, to avoid creating global variables;
  • lines 9–14: the submission of each form bearing the attribute [data-confirmation] is intercepted. [preventDefault()] suspends the submission; the form is stored, the question is placed in the window ([textContent], not [innerHTML]: the question is displayed as text, never interpreted as HTML), and the window is displayed;
  • Lines 19–25: “Yes” submits the cached form. The [submit()] method of a form submits it without triggering the [submit] event: there is no new confirmation prompt. “No” hides the window.

If [JavaScript] is disabled, the forms simply submit without confirmation: the application still works. This is the principle of incremental improvement: [JavaScript] adds convenience, but it is not essential. The case study follows this principle (its script, [wwwroot/js/app.js], adds keyboard handling: the Esc key and focus). The resulting pages, generated from http://localhost:5000/contacts: the list, the confirmation prompt, the list after deletion, and a contact’s profile:

Image

Image

Image

Image

Finally, we verify that deletion cannot be triggered by a query GET:

curl -i http://localhost:5000/contacts/2/delete
1
2
3
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Allow: POST

The path exists, but not for the GET method: [ASP.NET Core] returns [405 Method Not Allowed], and the [Allow] header indicates the accepted method. With POST:

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