Click here to Skip to main content
15,881,380 members
Articles / Web Development / Blazor

A Blazor Validation Control

Rate me:
Please Sign up or sign in to vote.
3.20/5 (2 votes)
16 Mar 2021CPOL6 min read 12.1K   8  
A Blazor validation control to manage and monitor validation state in a form.
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.

EditForm

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:

Razor
<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:

  1. If a delegate is registered with OnSubmit, it triggers it and ignores validation.
  2. 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.

  1. 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.
  2. 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.
  3. 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:

C#
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:

C#
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.

C#
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;
}
C#
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);
}

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:

  1. StringValidatorExtensions is a static class declaring as an extension method to string.
  2. 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.

C#
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 its 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.

C#
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.

C#
public interface IValidation
{
    public bool Validate(ValidationMessageStore validationMessageStore, string fieldname, object model = null);
}

WeatherForecast

WeatherForecast is a typical data class.

  1. It implements IValidation so the control can run validation.
  2. Each field is declared as a property with default values.
  3. It implements IValidation.Validate which calls three validations.

Each validation:

  1. Calls the Validation extension method on the type.
  2. Calls one or more validation chain methods.
  3. Calls Validate to log any validation messages to the ValidationMessageStore on EditContext and if necessary, trips the tripwire.
C#
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.

  1. It captures the cascaded EditContext.
  2. 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.
  3. ValidStateChanged is a callback for the parent to attach an event handler if required.
  4. IsValid is a public readonly property exposing the current validation state. It checks if EditContext has any validation messages.
  5. ValidationMessageStore is the EditContext's ValidationMessageStore.
  6. validating is a boolean field to ensure we don't stack validations.
  7. disposedValue is part of the IDisposable implementation.
C#
[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.

C#
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.

C#
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.

C#
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.

C#
public void Clear()
    => this.validationMessageStore.Clear();

<span class="pl-c">// 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()
{
    <span class="pl-c">// Do not change this code. 
                       // Put cleanup code in 'Dispose(bool disposing)' method
    Dispose(disposing: true);
    GC.SuppressFinalize(this);
}

A Simple Implementation

EditForm

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.

Razor
@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>
C#
@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:

  1. The UI hasn't updated. Step through the code to check what's happening when.
  2. 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

License

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


Written By
Retired Cold Elm
United Kingdom United Kingdom
Ex Geologist, Project Manager, Web Hoster, Business Owner and IT Consultant. Now, a traveller to places less travelled. And part time developer trying to keep up!

If you feel like saying thanks, the next time you see a charity request, DONATE. No matter how small, no matter who, it'll count. If you have a choice in the UK, Barnados.

Comments and Discussions

 
-- There are no messages in this forum --