The second article in a series looking at how to build Blazor edit forms/controls with state management, validation and form locking. This article focuses on validation state.
Overview - The Blazor ValidationFormState Control
This is the second in a series of articles describing a set of useful Blazor Edit controls that solve some of the current shortcomings in the out-of-the-box edit experience without the need to buy expensive toolkits.
This article covers how form validation works and shows how to build a relatively simple but fully featured validation system from scratch. Once the basic structure and classes are defined, it's easy to write additional validation chain methods for any new validation requirement or validator for a custom class.
Code and Examples
The repository contains a project that implements the controls for all the articles in this series. You can find it here.
The example site is at https://cec-blazor-database.azurewebsites.net/.
The example form described at this end of this article can be seen at https://cec-blazor-database.azurewebsites.net//validationeditor.
The Repo is a Work In Progress for future articles so will change and develop.
The Blazor Edit Setting
To begin, let's look at the out-of-the-box form controls and how validation works. A classic form looks something like this:
<EditForm Model="@exampleModel" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText id="name" @bind-Value="exampleModel.Name" />
<ValidationMessage For="@(() => exampleModel.Name)" />
<button type="submit">Submit</button>
</EditForm>
The first article describes the basic interacts of EditForm
and EditContext
so we'll skip that and concentrate on the validation process.
When the user clicks on the Submit button, EditForm
either:
- If a delegate is registered with
OnSubmit
, it triggers it and ignores validation. - If there's no
OnSubmit
delegate, it calls EditContext.Validate
. Depending on the result either triggers OnValidSubmit
or OnInvalidSubmit
.
EditContext.Validate
checks if there's a delegate registered for OnValidationRequested
and if so, runs it synchronously. Once complete, it checks if there are any messages in the ValidationMessageStore
. If it's empty, the form passes validation and OnValidSubmit
is invoked, otherwise OnInvalidSubmit
is invoked.
A Validator is a form component with no emitted markup. It's placed within EditForm
and captures the cascaded EditContext
. On initialization, it registers an event handler with EditContext.OnValidationRequested
to trigger validation. On validation, the validator does whatever it's coded to do, logs validation failure messages to the EditContext
ValidationMessageStore
and finally calls EditContext.NotifyValidationStateChanged
which triggers EditContext.OnValidationStateChanged
.
Validation Controls
Controls such as ValidationMessage
and ValidationSummary
capture the cascaded EditContext
and register event handlers on EditContext.OnValidationStateChanged
. When triggered, they check for any relevant messages and display them.
In the form shown above, <DataAnnotationsValidator />
adds the DataAnnotationsValidator
control to the form. This hooks in as described above, and uses the custom attribute annotations on the model class to validate values.
Validator
Validator
is the base validator class. It's declared abstract
and uses generics. Validators work on a chaining principle. The base class contains all the common boilerplate code.
- The first call is on an extension method defined for the object type to be validated. Each object type needs its own extension method to call its specific validator. This extension method returns the appropriate validator for the object type.
- Once you have the validator instance, you can chain as many validation methods as you wish together. Each is coded to run its validation test, log any specific messages to the validator, trigger the trip if necessary, and return the validator instance.
- Validation finishes by calling
Validate
, which trips the passed tripwire if necessary, and logs all the validation messages to the ValidationMessageStore
.
The Validator
Properties/Fields are:
public bool IsValid => !Trip;
public List<string> Messages { get; } = new List<string>();
protected bool Trip { get; set; } = false;
protected string FieldName { get; set; }
protected T Value { get; set; }
protected string DefaultMessage { get; set; } = "The value failed validation";
protected ValidationMessageStore ValidationMessageStore { get; set; }
protected object Model { get; set; }
The constructor populates the validator
:
public Validator(T value, string fieldName, object model,
ValidationMessageStore validationMessageStore, string message)
{
this.FieldName = fieldName;
this.Value = value;
this.Model = model;
this.ValidationMessageStore = validationMessageStore;
this.DefaultMessage = string.IsNullOrWhiteSpace(message) ? this.DefaultMessage : message;
}
There are two Validate
methods: a public
method for external usage and a protected
one for specific validators to override.
public virtual bool Validate(ref bool tripwire, string fieldname, string message = null)
{
if (string.IsNullOrEmpty(fieldname) || this.FieldName.Equals(fieldname))
{
this.Validate(message);
if (!this.IsValid)
tripwire = true;
}
else this.Trip = false;
return this.IsValid;
}
protected virtual bool Validate(string message = null)
{
if (!this.IsValid)
{
message ??= this.DefaultMessage;
if (this.Messages.Count == 0) Messages.Add(message);
var fi = new FieldIdentifier(this.Model, this.FieldName);
this.ValidationMessageStore.Add(fi, this.Messages);
}
return this.IsValid;
}
protected void LogMessage(string message)
{
if (!string.IsNullOrWhiteSpace(message)) Messages.Add(message);
}
StringValidator
Let's look at StringValidator
as an example implementation of a validator. The full set of validators is in the Repo. There are two classes:
StringValidatorExtensions
is a static
class declaring as an extension method to string
. StringValidator
is a implementation of Validator
specifically for string
s.
StringValidatorExtensions
declares a single static extension method Validation
for string
. It returns a StringValidator
instance. Call StringValidator
on any string
to initialise a validation chain.
public static class StringValidatorExtensions
{
public static StringValidator Validation(this string value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message = null)
{
var validation = new StringValidator(value, fieldName, model,
validationMessageStore, message);
return validation;
}
}
StringValidator
inherits from Validator
and declares the specific validation chain methods for string
s. Each runs its test. If validation fails, it logs any provided message to the message store and trips the tripwire. Finally, it returns this
. For string
s, we have two length methods and a RegEx
method to cover most circumstances.
public class StringValidator : Validator<string>
{
public StringValidator(string value, string fieldName,
object model, ValidationMessageStore validationMessageStore, string message) :
base(value, fieldName, model, validationMessageStore, message) { }
public StringValidator LongerThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length > test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator ShorterThan(int test, string message = null)
{
if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length < test))
{
Trip = true;
LogMessage(message);
}
return this;
}
public StringValidator Matches(string pattern, string message = null)
{
if (!string.IsNullOrWhiteSpace(this.Value))
{
var match = Regex.Match(this.Value, pattern);
if (match.Success && match.Value.Equals(this.Value)) return this;
}
this.Trip = true;
LogMessage(message);
return this;
}
}
IValidation
The IValidation
interface looks like this. It simply defines a Validate
method.
public interface IValidation
{
public bool Validate(ValidationMessageStore validationMessageStore, string fieldname, object model = null);
}
WeatherForecast
WeatherForecast
is a typical data class.
- It implements
IValidation
so the control can run validation. - Each field is declared as a property with default values.
- It implements
IValidation.Validate
which calls three validations.
Each validation:
- Calls the
Validation
extension method on the type. - Calls one or more validation chain methods.
- Calls
Validate
to log any validation messages to the ValidationMessageStore
on EditContext
and if necessary, trips the tripwire.
public class WeatherForecast : IValidation
{
public int ID { get; set; } = -1;
public DateTime Date { get; set; } = DateTime.Now;
public int TemperatureC { get; set; } = 0;
[NotMapped] public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; } = string.Empty;
public bool Validate(ValidationMessageStore validationMessageStore,
string fieldname, object model = null)
{
model = model ?? this;
bool trip = false;
this.Summary.Validation("Summary", model, validationMessageStore)
.LongerThan(2, "Your description needs to be a little longer! 3 letters minimum")
.Validate(ref trip, fieldname);
this.Date.Validation("Date", model, validationMessageStore)
.NotDefault("You must select a date")
.LessThan(DateTime.Now.AddMonths(1), true, "Date can only be up to 1 month ahead")
.Validate(ref trip, fieldname);
this.TemperatureC.Validation("TemperatureC", model, validationMessageStore)
.LessThan(70, "The temperature must be less than 70C")
.GreaterThan(-60, "The temperature must be greater than -60C")
.Validate(ref trip, fieldname);
return !trip;
}
}
ValidationFormState Control
The ValidationFormState
control replaces the basic Validator
provided with Blazor.
- It captures the cascaded
EditContext
. DoValidationOnFieldChange
controls field level validation. If true
, it validates a field when a user exits the field. If false
, it only responds to form level validation requests through EditContext
. ValidStateChanged
is a callback for the parent to attach an event handler if required. IsValid
is a public readonly
property exposing the current validation state. It checks if EditContext
has any validation messages. ValidationMessageStore
is the EditContext
's ValidationMessageStore
. validating
is a boolean field to ensure we don't stack validations. disposedValue
is part of the IDisposable
implementation.
[CascadingParameter] public EditContext EditContext { get; set; }
[Parameter] public bool DoValidationOnFieldChange { get; set; } = true;
[Parameter] public EventCallback<bool> ValidStateChanged { get; set; }
public bool IsValid => !EditContext?.GetValidationMessages().Any() ?? true;
private ValidationMessageStore validationMessageStore;
private bool validating = false;
private bool disposedValue;
When the component initializes, it gets the ValidationMessageStore
from EditContext
. It checks if it's running field level validation, and if so, registers FieldChanged
with EditContext.OnFieldChanged
event. Finally, it registers ValidationRequested
with EditContext.OnValidationRequested
.
protected override Task OnInitializedAsync()
{
Debug.Assert(this.EditContext != null);
if (this.EditContext != null)
{
this.validationMessageStore = new ValidationMessageStore(this.EditContext);
if (this.DoValidationOnFieldChange)
this.EditContext.OnFieldChanged += FieldChanged;
this.EditContext.OnValidationRequested += ValidationRequested;
}
return Task.CompletedTask;
}
The two event handlers call Validate
, one with and one without the field name.
private void FieldChanged(object sender, FieldChangedEventArgs e)
=> this.Validate(e.FieldIdentifier.FieldName);
private void ValidationRequested(object sender, ValidationRequestedEventArgs e)
=> this.Validate();
The comments within Validate
explain what it's doing. It casts the Model
as an IValidator
and check if it's valid. If so, it calls the Validate
method on the interface. We've seen model.Validate
in the WesatherForecast
data class. When it passes a fieldname
to Validate
, it only clears any validation messages for that specific fieldname
.
private void Validate(string fieldname = null)
{
var validator = this.EditContext.Model as IValidation;
if (validator != null || !this.validating)
{
this.validating = true;
if (string.IsNullOrEmpty(fieldname))
this.validationMessageStore.Clear();
else
validationMessageStore.Clear
(new FieldIdentifier(this.EditContext.Model, fieldname));
validator.Validate(validationMessageStore, fieldname, this.EditContext.Model);
this.EditContext.NotifyValidationStateChanged();
this.ValidStateChanged.InvokeAsync(this.IsValid);
this.validating = false;
}
}
The rest of the code consists of utility methods and IDisposable
implementation.
public void Clear()
=> this.validationMessageStore.Clear();
<span class="pl-c">
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
if (this.EditContext != null)
{
this.EditContext.OnFieldChanged -= this.FieldChanged;
this.EditContext.OnValidationRequested -= this.ValidationRequested;
}
}
disposedValue = true;
}
}
public void Dispose()
{
<span class="pl-c">
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
A Simple Implementation
To test the component, here's a simple test page.
Change the temperature up and down and you should see the buttons change colour and Text, and enabled/disabled state. Change the Temperature to 200 to get a validation message.
You can see this at https://cec-blazor-database.azurewebsites.net//validationeditor.
@using Blazor.Database.Data
@page "/validationeditor"
<EditForm Model="@Model" OnValidSubmit="@HandleValidSubmit">
<EditFormState @ref="editFormState" EditStateChanged="this.EditStateChanged">
</EditFormState>
<ValidationFormState @ref="validationFormState"></ValidationFormState>
<label class="form-label">ID:</label> <InputNumber class="form-control"
@bind-Value="Model.ID" />
<label class="form-label">Date:</label> <InputDate class="form-control"
@bind-Value="Model.Date" /><ValidationMessage For="@(() => Model.Date)" />
<label class="form-label">Temp C:</label> <InputNumber class="form-control"
@bind-Value="Model.TemperatureC" /><ValidationMessage For="@(() => Model.TemperatureC)" />
<label class="form-label">Summary:</label> <InputText class="form-control"
@bind-Value="Model.Summary" /><ValidationMessage For="@(() => Model.Summary)" />
<div class="mt-2">
<div>Validation Messages:</div>
<ValidationSummary />
</div>
<div class="text-right mt-2">
<button class="btn @btnStateColour" disabled>@btnStateText</button>
<button class="btn @btnValidColour" disabled>@btnValidText</button>
<button class="btn btn-primary" type="submit" disabled="@_btnSubmitDisabled">
Submit</button>
</div>
</EditForm>
@code {
protected bool _isDirty = false;
protected bool _isValid => validationFormState?.IsValid ?? true;
protected string btnStateColour => _isDirty ? "btn-danger" : "btn-success";
protected string btnStateText => _isDirty ? "Dirty" : "Clean";
protected string btnValidColour => !_isValid ? "btn-danger" : "btn-success";
protected string btnValidText => !_isValid ? "Invalid" : "Valid";
protected bool _btnSubmitDisabled => !(_isValid && _isDirty);
protected EditFormState editFormState { get; set; }
protected ValidationFormState validationFormState { get; set; }
private WeatherForecast Model = new WeatherForecast()
{
ID = 1,
Date = DateTime.Now,
TemperatureC = 22,
Summary = "Balmy"
};
private void HandleValidSubmit()
=> this.editFormState.UpdateState();
private void EditStateChanged(bool editstate)
=> this._isDirty = editstate;
}
Wrap Up
Hopefully, I've explained how validation works and how to build a simple, but comprehensive and extensible validation system.
The most common problem with validation is ValidationMessage
controls not showing messages. There are normally two reasons for this:
- The UI hasn't updated. Step through the code to check what's happening when.
- The
FieldIdentifier
generated from the For
property of ValidationMessage
doesn't match the FieldIdentifier
in the validation store. Check the FieldIdentifier
you're generating and logging to the validation store.
The next article shows how to lock out the form and prevent navigation when the form is dirty.
If you've found this article well into the future, the latest version will be available here.
History
- 16th March, 2021: Initial version