Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web

Build Rich Web Apps with ASP.NET Core and Sircl – Part 3

5.00/5 (1 vote)
4 May 2024CPOL12 min read 7.2K   55  
In this series, we will see how to build interactive web applications in ASP.NET Core with the help of Sircl.
In the previous article, we saw how to combine dynamic behaviours with page part requests to build rich and dynamic web pages. We continue on this journey adding list management and discover some form specific features.

Introduction

In this series, we will see how to build great interactive web applications – applications that typically require extensive amounts of JavaScript code or are written in JavaScript frameworks – easily with only ASP.NET Core and Sircl.

Sircl is an open-source client-side library that extends HTML to provide partial updating and common behaviours and makes it easy to write rich applications relying on server-side rendering.

In each part of this series, we will cover a "programming problem" typical to rich web applications using server-side technology, and see how we can solve this problem in ASP.NET Core using Sircl.

In the previous article, we saw how to combine dynamic behaviours with page part requests to build rich and dynamic web pages. We continue on this journey adding list management and discover some form specific features.

List Management

Today, we will build a simple flight registration form: the user chooses a flight (date and airports) and enters one or more passengers.

Since the number of passengers is not known beforehand, the form offers a way to add (and to remove) passengers.

Or ViewModel classes are the following:

C#
public class IndexModel
{
    public FlightModel Flight { get; set; } = new();

    public IEnumerable<SelectListItem>? FromAirports { get; internal set; }

    public IEnumerable<SelectListItem>? ToAirports { get; internal set; }
}

public class FlightModel
{
    [Required]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    public DateOnly? Date { get; set; }

    [Required]
    public string? FromAirport { get; set; }

    [Required]
    public string? ToAirport { get; set; }

    public List<PassengerModel> Passengers { get; internal set; } = new();
}

public class PassengerModel
{
    [Required]
    public string? FirstName { get; set; }

    [Required]
    public string? LastName { get; set; }
}

The DisplayFormat attribute on the Date property is required to correctly format the date for use with the INPUT element with type "date".

The FlightModel contains a list of passengers of type PassengerModel.

The following form can be build to match this model:

Let’s start with the flight details (date, from and to airport). A particularity is that the list of destination airports will depend on the chosen date and departure airport as it should only offer existing flights.

For this simulation, we will only make sure the destination airport list does not include the departure airport, as arriving and departing from the same airport makes no sense...

This can be achieved with the following Index controller action:

C#
public IActionResult Index(IndexModel model)
{
    ModelState.Clear();
    return ViewIndex(model);
}

private IActionResult ViewIndex(IndexModel model)
{
    // Set airport select items:
    model.FromAirports = DataStore.Airports
        .Select(a => new SelectListItem(a, a, model.Flight.FromAirport == a));
    model.ToAirports = DataStore.Airports
        .Where(a => a != model.Flight.FromAirport) // exclude departure airport
        .Select(a => new SelectListItem(a, a, model.Flight.ToAirport == a));

    // Return view:
    return View("Index", model);
}

Note we have split the method in two: the Index action method, and a ViewIndex method to return the Index view. This makes it easier to return the Index view from other action methods.

In the ViewIndex method, when setting the ToAirports, we exclude the chosen FromAirport. This way, the user cannot choose the same airport for arrival and departure.

But this code is only executed when initially rendering the view. Let us now use page part rendering to also use this code to update the view "on the fly", when a user selects a different departure airport.

First of all, add the Sircl library to your project, either by referencing the Sircl library files on a CDN from within the _Layout.cshtml file as we have done in Part 1, or by any way described on this page.

Next, we specify that the form needs to be refreshed whenever the FromAirport is changed. This is done by adding the class onchange-submit to the FromAirport SELECT control.

If the form’s action points to the Index action, this will submit the form and re-render it. So far, a full page request will be used since no inline target has been specified. And this means a full page load occurs on every change. We can switch to page part requests by specifying a target and have the server return a partial view when requested:

  1. add a target class to the form to make the form the inline target of partial page requests
     
  2. replace the last line of the IndexView controller method into the following code to return either a full page or a partial view:
C#
// Return full view or partial view:
if (Request.Headers["X-Sircl-Request-Type"] == "Partial")
    return PartialView("Index", model);
else
    return View("Index", model);

Alternative to this second step is to add code in the _Layout.cshtml file or the _ViewStart.cshtml file as described in Part 2 of this series.

For an optimal experience, we can define the ToAirport control as the sub target by means of a sub-target attribute. This will ensure only the list of values of the destination airport select control will be updated. This is how the form looks like for now (presentation elements stripped):

ASP.NET
@model FlightModel

<form asp-action="Index" method="post" class="target">

<h2>Book your flight</h2>

<fieldset>
    <legend>Flight</legend>

    <div>
        <label>Date:</label>
        <input asp-for="Flight.Date" type="date">
    </div>

    <div>
        <label>From:</label>
        <select asp-for="Flight.FromAirport" 
         asp-items="Model.FromAirports" class="onchange-submit"
            sub-target="#@Html.IdFor(m => m.Flight.ToAirport)">
            <option value="">(Select an airport)</option>
        </select>
    </div>

    <div>
        <label>To:</label>
        <select asp-for="Flight.ToAirport" asp-items="Model.ToAirports">
            <option value="">(Select an airport)</option>
        </select>
    </div>
</fieldset>

</form>

Notice how the sub-target attribute uses the Html.IdFor function prefixed by a hash (#). The sub-target value is a CSS selector and CSS selectors referring to elements by id start with a hash. Of course, you do not need to use the IdFor function if you know the element’s id.

Adding Passengers

Adding a passenger encompasses adding a PassengerModel object to the Passengers list of the model. This will result in a set of additional FirstName and LastName fields for the user to complete. So server-side, the action method to add a passenger is:

C#
public IActionResult AddPassenger(FlightModel model)
{
    ModelState.Clear();
    model.Flight.Passengers.Add(new());
    return ViewIndex(model);
}

Clearing the model state ensures no validation errors are returned but also ensures no stale values are displayed in the list. Whenever an action method’s purpose is to manipulating the model object, it should clear the model state.

The AddPassenger action method adds a (blank) passenger object to the passengers list and returns the Index view.

Removing Passengers

To remove a passenger, we need to know which one to remove, so we expect an index to be given. The remove action method is:

C#
public IActionResult RemovePassenger(FlightModel model, int index)
{
    ModelState.Clear();
    model.Flight.Passengers.RemoveAt(index);
    return ViewIndex(model);
}

To the form, add the following fieldset to render the passenger list, including buttons to add and remove passengers:

ASP.NET
<fieldset>
    <legend>Passengers</legend>
    @for(int i=0; i<Model.Flight.Passengers.Count; i++)
    {
        <div
            <span class="float-end">
                <button type="submit"
                  formaction="@Url.Action("RemovePassenger", new { index = i })">
                  &times;
                </button>
            </span>
            <p>Passenger @(i + 1): </p>
            <div>
                <div>
                    <input asp-for="Flight.Passengers[i].FirstName">
                </div>
                <div>
                    <input asp-for="Flight.Passengers[i].LastName">
                </div>
            </div>
        </div>
    }
    <div>
        <button type="submit" formaction="@Url.Action("AddPassenger")">
            Add passenger
        </button>
    </div>
</fieldset>

Since the form has a target class, all form submit actions are partial page requests returning the entire form (not the entire page). You could add a sub-target to only update the passengers fieldset or even smaller parts.

Submitting the Form

When the form is completed by the user, (s)he must be able to submit the form and go to the "next" step.

If we add a regular submit button, the form will be submitted to the Index action using a page part request. To override the action we can simply set the asp-action attribute (or HTML formaction attribute) to the desired action (url).

To override the inline target we can set the HTML formtarget attribute. We can set it to a CSS selector to point to another inline target, or we can set it to _self to request a full page load:

ASP.NET
<button type="submit" asp-action="Next" formtarget="_self">
    Next
</button>

You can also add an icon to the button. Add for instance Boostrap Icons by adding the following line in the HEAD section of the _Layout.cshtml file:

ASP.NET
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css">

Then add an icon to the button as follows:

ASP.NET
<button type="submit" asp-action="Next" formtarget="_self">
    <i class="bi bi-caret-right-fill"></i>
    Next
</button>

The Next controller action method is the place where we validate the form. Here we can add additional validation rules not supported or implemented by the DataAnnotation attributes on our model:

C#
[HttpPost]
public IActionResult Next(IndexModel model)
{
    // Ensure flight is not in the past:
    if (model.Flight.Date.HasValue && 
        model.Flight.Date.Value < DateOnly.FromDateTime(DateTime.Now))
    {
        ModelState.AddModelError("Flight.Date", "Flight date cannot be in the past.");
    }
    // Ensure from and to airports are different:
    if (model.Flight.FromAirport != null && 
        model.Flight.FromAirport == model.Flight.ToAirport)
    {
        ModelState.AddModelError("Flight.ToAirport", 
        "Departure airport cannot be the same as destination airport.");
    }
    // Ensure at least one passenger is registered:
    if (model.Flight.Passengers.Count == 0)
    {
        ModelState.AddModelError("", "There must be at least one passenger.");
    }

    // If valid, go next:
    if (ModelState.IsValid)
    {
        return View("Next");
    }

    // Otherwise, return the index view (with validation errors):
    return ViewIndex(model);
}

If the form is valid, the Next view is rendered. Otherwise, the Index view is returned. At this point, this view should also display validation errors.

For each field, add an element with the asp-validation-for attribute to the corresponding field, as in:

ASP.NET
<span asp-validation-for="Flight.Date" class="text-danger"></span>

In addition, because some validation errors are not related to a particular field, you should also add the following code. Place it just below the H2 header:

ASP.NET
<div asp-validation-summary="All" class="alert 
     alert-danger alert-dismissible fade show mb-3">
    <strong>Following errors have occured:</strong>
    <button type="button" class="btn-close" data-bs-dismiss="alert" 
                          aria-label="Close"></button>
</div>

To not show this section when there are no validation errors, add this little CSS. You can add this in the _Layout.cshtml file, or even better, in the /wwwroot/css/site.css file (without the style element tag then of course):

ASP.NET
<style>
    .validation-summary-valid {
        display: none;
    }
</style>

That’s it. We now have a nice, fully functional flight registration form based exclusively on C# code and HTML.

Quote:

We now have a nice, fully functional form based exclusively on C# code and HTML.

Change Detection

At this point, our form is operational. The user can enter the form and go next. If the user enters the form and then – by distraction or other – clicks on the wrong link, for instance on the "Privacy" link, the entered data will be lost.

Change detection in Sircl allows for one to detect if a form contains changes (and maybe on submit act accordingly) and for two to have the web page protected against loss of data.

Changes in the form can occur from two sources: the user interacts with INPUT or TEXTAREA controls (checks checkboxes, changes values), or the server changes the model data. Take for instance a completed flight registration form where the user clicks to remove one passenger. This also changes the data. So both the web client and the server are be able to initiate change.

In addition, when a changed form is submitted and returned because of validation errors, the form must remain in changed state as previous changes were not saved.

To achieve all this, we add HasChanges property (you can name it differently) to the IndexModel class:

C#
public class IndexModel
{
    public bool HasChanges { get; set; }

    ...
}

In the form, we add a hidden field for this property. And on the form element, we add an onchange-set attribute with the name of the HasChanges property:

ASP.NET
<form asp-action="Index" method="post" class="target"
      onchange-set="HasChanges">
    <input type="hidden" asp-for="HasChanges" />
    ...
</form>

Whenever a change occurs in the form, Sircl will set the field named HasChanges to the value true.

In the controller actions that manipulate the model data (AddPassenger and RemovePassenger), we also set the HasChanges property to true. Add the following line to both action methods:

C#
model.HasChanges = true;

The form will now be aware of whether it contains changes and this information reaches the server through the HasChanges model property. And since Sircl adds a form-changed class to forms in changed state, you could use CSS to style the form or (some of) it’s elements dependently.

But we haven’t protected the form against losing this changes by clicking an a random link.
For this last step, add an onunloadchanged-confirm attribute with a message to show the user when changes could be lost:

ASP.NET
<form asp-action="Index" method="post" class="target"
      onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?">
    <input type="hidden" asp-for="HasChanges" />
    ...
</form>

Now, when the user changes the form, then clicks a random link, confirmation will be asked.

Find more about Changed state management in Sircl on this page.

Note that this does not protect against closing the browser window or navigating away using a browser feature (reload or back button, or clicking a favorite). For this you need to implement a onbeforeunload handler in your page.

More information: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event

Spinners

Sircl offers several ways to inform the user a partial page request is happening. This includes overlays (semi-transparent block over a page section), progress bars and spinners. Let’s add spinners to the various form buttons.

To add a spinner with Sircl, simply add an element with the spinner class inside the button (or hyperlink). During the request, the spinner element will then be replaced by a spinning wheel. Once the request is done, the element is restored.

This is the Add passenger button with spinner:

ASP.NET
<button type="submit" formaction="@Url.Action("AddPassenger")">
    <span class="spinner"></span>
    Add passenger
</button>

To replace the cross (x) by a spinner while removing a passenger, we place the cross inside the spinner element. While spinning, the whole element is replaced by a spinning wheel:

ASP.NET
<button type="submit"
    formaction="@Url.Action("RemovePassenger", new { index = i })">
    <span class="spinner">&times;</span>
</button>

And for the Next button, we can add the spinner class to the icon so that the icon is replaced by the spinning wheel. But there is one more issue here. Since Sircl can only restore the spinner when performing Ajax calls, spinners and other features are not by default enabled on elements that perform a regular (browser controlled) navigation. To opt-in for these features on regular navigations as well, add the onnavigate class to the trigger element (the BUTTON) or any of it’s parents (including the BODY element):

ASP.NET
<button type="submit" asp-action="Next" class="onnavigate" formtarget="_self">
    <i class="bi bi-caret-right-fill spinner"></i>
    Next
</button>

By default, Sircl constructs a spinner using a CSS animation matching default web styling. If you are using Bootstrap, you will find it a better choice to use a Bootstrap spinner instead. Sircl automatically uses a Bootstrap spinner if you include the Sircl Bootstrap library by for instance adding the following line to the _Layout.cshtml file (after adding the sircl.min.js file):

ASP.NET
<script src="https://cdn.jsdelivr.net/npm/sircl@2.3.7/sircl-bootstrap5.min.js"></script>

If you are using Font Awesome, you can (also) include the Font Awesome Sircl extension, which basically does nothing else than override the spinner element:

ASP.NET
<script src="https://cdn.jsdelivr.net/npm/sircl@2.3.7/sircl-fa.min.js"></script>

By the way, on the sample code of this article, I have added delays to various controller actions so you have time to see the spinners in action.

Avoiding Resubmission

Spinners are a nice way to acknowledge for action of the user. Without it, if the request takes too long, the user might become impatient and press the button multiple times by this issuing multiple requests and possibly flooding an already overly busy server.

But spinners do not prevent users from clicking multiple times on it. And I have seen users (including my young kids) pressing buttons many times expecting things will go quicker...

With Sircl, avoiding multiple submits of a form is a simple matter of adding an onsubmit-disable class on the form. This will disable all submit controls when a submit request is pending. Simple and effective!

ASP.NET
<form asp-action="Index" method="post" class="target onsubmit-disable"
      onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?">
    ...
</form>

Keyboard Support

For some (web) forms, keyboard support is important. For others, it often gets insufficient attention. In our current form, we do have one issue with the keyboard: pressing ENTER when in the Date field will delete the first passenger or add one!

To understand why, we have to understand how HTML forms handle an ENTER key press. By default, an ENTER submits the form using the first submit button. If no submit button is found, the form is submitted using the forms action URL or the current page URL.

In our case, we want the Next submit button to be triggered, which is not the first submit button in the form.

To override the default button with Sircl, add an onkeyenter-click attribute to the form with a CSS selector to the wanted button. For instance add an id attribute to the Next button and add an onkeyenter-click attribute referring to that id on the form element:

ASP.NET
<form asp-action="Index" method="post" class="target onsubmit-disable"
      onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?"
      onkeyenter-click="#nextbtn">
    ...
    <button id="nextbtn" type="submit" asp-action="Next" 
     class="onnavigate" formtarget="_self">
        <i class="bi bi-caret-right-fill spinner"></i>
        Next
    </button>
</form>

In the attached code, you will also see how I used the autofocus attribute to set the focus on the First name field of newly added passenger lines. This also facilitates keyboard entry.

Conclusion

In this article, we have shown how – by combining ASP.NET Core (MVC) with Sircl – we can create interactive web forms and apps using exclusively (imperative) C# code and (declarative) HTML. We have also seen that Sircl contains features to easily solve common web form issues.

In the next article, we will see how Bootstrap Modals (or HTML 5 Dialogs) can be used to further enhance our web apps and how Sircl makes them easy to use.

In the meantime, find all about Sircl on https://www.getsircl.com/.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)