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.
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 here 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 three articles are:
There's also an article on building a Modal Dialog Editor here.
To begin lets 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:
OnSubmit
, it triggers it and ignores validation.OnSubmit
delegate, it calls EditContext.Validate
. Depending on the result either triggers OnValidSubmit
or OnInvalidSubmit
.EditContext.Validate
checks if there's a delagate 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
.
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
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.
Validate
, which trips the passed tripwire if necessary, and log 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; // Check if we've logged specific messages. If not add the default message if (this.Messages.Count == 0) Messages.Add(message); //set up a FieldIdentifier and add the message to the Edit Context ValidationMessageStore 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); }
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 strings.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 strings. Each runs it's test. If validation fails it logs any provided message to the message store and trips the tripwire. Finally it returns this
. For strings, 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) { } /// Check of the string is longer than test public StringValidator LongerThan(int test, string message = null) { if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length > test)) { Trip = true; LogMessage(message); } return this; } /// Check if the string is shorter than public StringValidator ShorterThan(int test, string message = null) { if (string.IsNullOrEmpty(this.Value) || !(this.Value.Length < test)) { Trip = true; LogMessage(message); } return this; } /// Check if the string is matches a RegEx pattern 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; } }
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
is a typical data class.
IValidation
so the control can run validation.IValidation.Validate
which calls three validations.Each validation:
Validation
extension method on the type.Validate
to log any validation messages to the ValidationMessageStore
on EditContext
and if necessary trip 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; } }
The ValidationFormState
control replaces the basic Validator provided with Blazor.
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) { // Get the Validation Message Store from the EditContext this.validationMessageStore = new ValidationMessageStore(this.EditContext); // Wires up to the EditContext OnFieldChanged event if (this.DoValidationOnFieldChange) this.EditContext.OnFieldChanged += FieldChanged; // Wires up to the Editcontext OnValidationRequested event 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) { // Checks to see if the Model implements IValidation var validator = this.EditContext.Model as IValidation; if (validator != null || !this.validating) { this.validating = true; // Check if we are doing a field level or form level validation // Form level - clear all validation messages // Field level - clear any field specific validation messages if (string.IsNullOrEmpty(fieldname)) this.validationMessageStore.Clear(); else validationMessageStore.Clear(new FieldIdentifier(this.EditContext.Model, fieldname)); // Run the IValidation interface Validate method validator.Validate(validationMessageStore, fieldname, this.EditContext.Model); // Notify the EditContext that the Validation State has changed - // This precipitates a OnValidationStateChanged event which the validation message controls are all plugged into this.EditContext.NotifyValidationStateChanged(); // Invoke ValidationStateChanged this.ValidStateChanged.InvokeAsync(this.IsValid); this.validating = false; } }
The rest of the code consists of utility methods and IDisposable
implementation.
/// <summary> /// Method to clear the Validation and Edit State /// </summary> public void Clear() => this.validationMessageStore.Clear(); // IDisposable Implementation 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() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); }
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; }
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:
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