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:
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:
public IActionResult Index(IndexModel model)
{
ModelState.Clear();
return ViewIndex(model);
}
private IActionResult ViewIndex(IndexModel model)
{
model.FromAirports = DataStore.Airports
.Select(a => new SelectListItem(a, a, model.Flight.FromAirport == a));
model.ToAirports = DataStore.Airports
.Where(a => a != model.Flight.FromAirport)
.Select(a => new SelectListItem(a, a, model.Flight.ToAirport == a));
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:
- add a
target
class to the form to make the form the inline target of partial page requests
- replace the last line of the
IndexView
controller method into the following code to return either a full page or a 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):
@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:
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 passenger
s 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:
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:
<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 })">
×
</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 passenger
s 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:
<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:
<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:
<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:
[HttpPost]
public IActionResult Next(IndexModel model)
{
if (model.Flight.Date.HasValue &&
model.Flight.Date.Value < DateOnly.FromDateTime(DateTime.Now))
{
ModelState.AddModelError("Flight.Date", "Flight date cannot be in the past.");
}
if (model.Flight.FromAirport != null &&
model.Flight.FromAirport == model.Flight.ToAirport)
{
ModelState.AddModelError("Flight.ToAirport",
"Departure airport cannot be the same as destination airport.");
}
if (model.Flight.Passengers.Count == 0)
{
ModelState.AddModelError("", "There must be at least one passenger.");
}
if (ModelState.IsValid)
{
return View("Next");
}
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:
<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:
<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):
<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:
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:
<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:
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:
<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:
<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:
<button type="submit"
formaction="@Url.Action("RemovePassenger", new { index = i })">
<span class="spinner">×</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):
<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):
<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:
<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!
<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:
<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/.