Articoli

Validazione avanzata in Blazor

da

Introduzione – Le basi

Nell’articolo introduttivo di Michele sulla validazione dei form in Blazor possiamo vedere quali sono le funzionalità che il framework ci offre “out of the box”. In questo articolo invece andiamo ad approfondire concetti un po’ più avanzati che ci permettono di personalizzare il comportamento della nostra applicazione.

Partiamo da un form per la gestione di un oggetto di tipo Person. Il comportamento del form è molto semplice, all’utente verrà richiesto di inserire un Name ed un Age e, se i valori inseriti sono corretti, mostra a video un messaggio di successo. Aggiungiamo al nostro progetto quindi la page PersonForm.razor e la classe Person.cs.

@page "/person-form"

<EditForm Model="@person" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />

    <div class="form-group">
        <label for="person-name">Name</label>
        <InputText id="person-name" class="form-control" @bind-Value="person!.Name" />
        <ValidationMessage For="(() => person!.Name)" />
    </div>

    <div class="form-group mt-2">
        <label for="person-age">Age</label>
        <InputNumber id="person-age" class="form-control" @bind-Value="person!.Age" />
        <ValidationMessage For="(() => person!.Age)" />
    </div>

    <div class="mt-2">
        <button class="btn btn-primary">Save</button>
    </div>
</EditForm>

@if (!string.IsNullOrWhiteSpace(message))
{
    <div class="alert alert-info mt-3">
        @message
    </div>
}

@code {
    private Person? person;
    private string? message;

    protected override void OnInitialized()
    {
        person = new();
    }

    private void HandleValidSubmit()
    {
        message = $"{person!.Name} is {person!.Age} years old";
    }
}
using System.ComponentModel.DataAnnotations;

namespace BlazorValidationSample.Models
{
    public class Person
    {
        [Required(ErrorMessage = "Name is required")]
        public string? Name { get; set; }

        [Range(0, 100, ErrorMessage = "Age must be between {1} and {2}")]
        public int? Age { get; set; }

    }
}
Come si presenta il nostro bellissimo form

L’uso del componente <DataAnnotationsValidator /> all’interno del component <EditForm /> ci permette di sfruttare le DataAnnotations messe sulle proprietà del modello per validare gli input. Per poter personalizzare il comportamento della validazione del form, però, è necessario introdurre l’oggetto EditContext.

EditContext – Metadati del form

L’EditContext è un oggetto che gestisce una collezione di metadati del form stesso e dell’oggetto che questo sta gestendo. Questo oggetto ci permette di conoscere in qualunque istante lo stato di validazione del form e di ogni singola proprietà del modello gestito dal form.

Andiamo a vedere come modificare il nostro EditForm per poter gestire l’uso dell’EditContext.

@page "/person-form"

<EditForm EditContext="@editContext" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />

    <div class="form-group">
        <label for="person-name">Name</label>
        <InputText id="person-name" class="form-control" @bind-Value="person!.Name" />
        <ValidationMessage For="(() => person!.Name)" />
    </div>

    <div class="form-group mt-2">
        <label for="person-number">Age</label>
        <InputNumber id="person-age" class="form-control" @bind-Value="person!.Age" />
        <ValidationMessage For="(() => person!.Age)" />
    </div>

    <div class="mt-2">
        <button class="btn btn-primary">Save</button>
    </div>
</EditForm>

@if (!string.IsNullOrWhiteSpace(message))
{
    <div class="alert alert-info mt-3">
        @message
    </div>
}

@code {
    private Person? person;
    private string? message;
    private EditContext editContext;

    protected override void OnInitialized()
    {
        person = new();
        editContext = new(person);
    }

    private void HandleValidSubmit()
    {
        message = $"{person!.Name} is {person!.Age} years old";
    }
}

Come vedete la modifica è stata molto semplice: abbiamo inserito un field per tenere la reference del nostro EditContext; questo oggetto viene inizializzato nel metodo OnInitialized del page component prendendo il model come input; e, invece di passare il parametro Model all’EditForm, andiamo a passare l’EditContext. Piccola nota: l’uso dei due parametri è mutuamente esclusivo, qualora si tentasse di usarli entrambi contemporaneamente al momento del render del form otterremo una InvalidOperationException.

Vediamo cosa ci permette di fare questo piccolo cambiamento.

Un tocco di stile – Cambiare la classe CSS dei controlli validi e non validi

Di default il meccanismo di validazione di ASP.NET utilizza le classi CSS “valid” e “invalid” per “stilizzare” gli input a valle del processo di validazione. Ma se noi volessimo cambiare questo comportamento?

Così si presenta il nostro form con le classi di validazione standard di ASP.NET

A tal fine ci viene in soccorso l’EditContext stesso! Tra le varie funzionalità che ci vengono esposte dall’oggetto, infatti, troviamo la possibilità di andare ad impostare il FieldCssClassProvider che è l’oggetto che si occupa di fornire i nomi delle classi CSS per i vari field del form a seconda del loro stato di validazione.

@page "/person-form"
@using BlazorValidationSample.Validation

<EditForm EditContext="@editContext" OnValidSubmit="HandleValidSubmit">
    ...
</EditForm>

@code {

    ...

    protected override void OnInitialized() 
    {
        person = new();
        editContext = new(person);
        editContext.SetFieldCssClassProvider(new CustomCssClassProvider());
    }

    ...

}

Tramite il metodo SetFieldCssClassProvider possiamo fornire all’EditContext una nostra classe, che estenderà la classe base FieldCssClassProvider, e che implementerà la logica per restituire i nomi delle classi css tramite l’override del metodo GetFieldCssClass. Nel metodo andiamo a verificare se, per il field in input identificato tramite FieldIdentifier, sono presenti messaggi di validazione; in caso negativo, ovvero non sono presenti errori di validazione per il campo, restituiremo la classe CSS “custom-valid”, altrimenti la stringa “custom-invalid”.

using Microsoft.AspNetCore.Components.Forms;

namespace BlazorValidationSample.Validation
{
    public class CustomCssClassProvider : FieldCssClassProvider
    {
        public override string GetFieldCssClass(EditContext editContext, in FieldIdentifier fieldIdentifier)
        {
            if (editContext.GetValidationMessages(fieldIdentifier).Any())
            {
                return "custom-invalid";
            }

            return "custom-valid";
        }
    }
}

Andiamo quindi a modificare il file css della nostra applicazioni aggiungendo due semplici regole di stile come quelle presenti nel file che segue ed il gioco sarà fatto!

.custom-valid.modified:not([type=checkbox]) {
    outline: 1px solid green;
    background-color: lightgreen;
}

.custom-invalid {
    outline: 1px solid red;
    background-color: pink;
}

Questo approccio ci permette anche, laddove sia richiesto, di restituire classi CSS differenti non solo a seconda della validità del campo ma anche, per esempio, a seconda del campo stesso: potremmo quindi avere un “invalid-date” per campi di tipo DateTime e un “invalid-number” per campi numerici andando quindi a “stilizzare” in maniera differente i nostri user control.

Questo sarà il nostro form dopo la modifica FieldCssClassProvider

Validità del form in tutte le salse

Un’altra delle funzionalità offerte dall’EditContext è quella di sapere in qualsiasi momento lo stato di validità del form di appartenenza. Questo ci permette, per esempio, di poter disabilitare il bottone per effettuare il submit del form fintanto che il form stesso non risulti valido secondo le nostre regole di business.

Definiamo intanto un field di tipo bool isFormValid che ci servirà ad abilitare e disabilitare il button per fare la submit. Sempre tramite il nostro caro EditContext, al momento dell’inizializzazione del component, possiamo attaccare all’evento OnFieldChanged l’event handler EditContext_HandleFieldChanged. L’evento FieldChanged verrà emesso ogni volta che l’utente andrà a modificare il valore di un campo; a questo punto ci siamo messi in ascolto dell’evento e possiamo andare a verificare se il form risulta valido e quindi settare a true il nostro field isFormValid.

Andiamo quindi nel metodo di Dispose del nostro component e rimuovere l’event handler per evitare eventuali leak di memoria.

@page "/person-form"
@using BlazorValidationSample.Validation

<EditForm EditContext="@editContext" OnValidSubmit="HandleValidSubmit">

    ...  

    <div class="mt-2">
        <button class="btn btn-primary" disabled="@(!isFormValid)">Save</button>
    </div>
</EditForm>

    ...

@code {
    private Person? person;
    private string? message;
    private bool isFormValid;
    private EditContext? editContext;

    protected override void OnInitialized()
    {
        isFormValid = false;
        person = new();
        editContext = new(person);
        editContext.SetFieldCssClassProvider(new CustomCssClassProvider());
        editContext.OnFieldChanged += EditContext_HandleFieldChanged;
    }

    private void HandleValidSubmit()
        => message = $"{person!.Name} is {person!.Age} years old";

    private void EditContext_HandleFieldChanged(object? sender, FieldChangedEventArgs args)
        => isFormValid = editContext!.Validate();

    public void Dispose()
    {
        if (editContext is not null)
        {
            editContext.OnFieldChanged -= EditContext_HandleFieldChanged;
        }
    }
}

Questa modifica tuttavia causa uno spiacevole side-effect. Chiamare il metodo Validate() dell’oggetto EditContext andrà a validare il form nella sua interezza e verranno quindi mostrati eventuali messaggi di errore anche nei campi che l’utente non ha ancora modificato.

Validation Provider personalizzati

L’EditContext non solo è disponibile nel component stesso direttamente come attributo dell’EditForm, ma viene propagato anche a tutti i componenti figli dell’EditForm come CascadingParameter.

Questo significa che possiamo andare a scriverci un nostro ValidationProvider personalizzato con regole che non potremmo scrivere con le sole DataAnnotations (come per esempio regole di validazione cross-campi).

Andiamo a semplificare un po’ il nostro form tornando alla versione iniziale. L’unica cosa che cambieremo sarà l’uso del component PersonValidationProvider al posto di DataAnnotationsValidationProvider.

@page "/person-form"
@using BlazorValidationSample.Validation

<EditForm Model="@person" OnValidSubmit="HandleValidSubmit">
    <PersonValidationProvider />

    <div class="form-group">
        <label for="person-name">Name</label>
        <InputText id="person-name" class="form-control" @bind-Value="person!.Name" />
        <ValidationMessage For="(() => person!.Name)" />
    </div>

    <div class="form-group mt-2">
        <label for="person-number">Age</label>
        <InputNumber id="person-age" class="form-control" @bind-Value="person!.Age" />
        <ValidationMessage For="(() => person!.Age)" />
    </div>

    <div class="mt-2">
        <button class="btn btn-primary">Save</button>
    </div>
</EditForm>

@if (!string.IsNullOrWhiteSpace(message))
{
    <div class="alert alert-info mt-3">
        @message
    </div>
}

@code {
    private Person? person;
    private string? message;

    protected override void OnInitialized()
    {
        person = new();
    }

    private void HandleValidSubmit()
        => message = $"{person!.Name} is {person!.Age} years old";
}

Creiamo la classe PersonValidationProvider come componente andando quindi ad estendere la classe ComponentBase; nel metodo OnInitialized inizializziamo un nuovo ValidationMessageStore, che è soltanto un contenitore di messaggi di validazione per il nostro EditContext, e aggiungiamo l’event handler ValidateModel all’evento OnValidationRequested dell’EditContext che abbiamo ottenuto come CascadingParameter. Volendo, come abbiamo visto nel paragrafo precedente, l’oggetto EditContext potrebbe fornirci anche la possibilità di sottoscriverci all’evento OnFieldChanged con cui possiamo andare a gestire la validazione anche per il singolo campo. Completiamo la classe PersonValidationProvider incapsulando la nostra logica di business nel metodo ValidateModel.

using BlazorValidationSample.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;

namespace BlazorValidationSample.Validation
{
    public class PersonValidationProvider : ComponentBase
    {
        [CascadingParameter]
        public EditContext? EditContext { get; set; }

        protected override void OnInitialized()
        {
            var messages = new ValidationMessageStore(EditContext!);

            EditContext!.OnValidationRequested += (sender, eventArgs)
                => ValidateModel((EditContext)sender!, messages);
        }

        private void ValidateModel(EditContext editContext, ValidationMessageStore messages)
        {
            messages.Clear();

            if (editContext.Model != null)
            {
                var person = editContext.Model as Person;

                if (person?.Age is null)
                {
                    messages.Add(editContext.Field("Age"), "Age is required");
                }
                else if (person?.Age > 100 || person?.Age < 0)
                {
                    messages.Add(editContext.Field("Age"), "Age must be between 0 and 100");
                }

                if (string.IsNullOrWhiteSpace(person?.Name))
                {
                    messages.Add(editContext.Field("Name"), "Name is required");
                }

                if (person?.Name == "Fabio" && person?.Age < 30)
                {
                    messages.Add(editContext.Field("Name"), "You whish you were so young, don't you?");
                }
            }
        }
    }
}

All’interno del metodo ValidateModel possiamo andare ad applicare tutte le logiche di validazione necessarie; per ciascuna regola di validazione non rispettate andiamo a popolare il ValidationMessageStore con il relativo messaggio di errore ed il campo a cui questo errore sarà associato (e quindi mostrato tramite il componente <ValidationMessage />.

La logica contenuta nella classe ValidationProvider può anche andare a sfruttare meccanismi di validazione più potenti come, per esempio, la libreria FluentValidation. Ad esempio potremmo sfruttare il fatto che il ValidationProvider è un Component e avrà quindi la possibilità di sfruttrare la Dependency Injection e ricevere i IValidator di FluentValidation come parameteri iniettati e potrà quindi sfruttarli a suo vantaggio nell’applicare regole di validazioni complesse.

La libreria Blazored/FluentValidation, ad esempio, sfrutta proprio i meccanismi che abbiamo visto sopra per portare il disaccoppiamento tra ViewModel e regole di validazione offerto dalla libreria FluentValidation all’interno delle nostre applicazioni Blazor.

Conclusioni

Nell’articolo abbiamo visto cosa è e come funziona l’EditContext, come è possibile impostare classi CSS custom alla validazione di un form al posto di quelle standard, come possiamo gestire l’abilitazione del button di submit di un form solo quando il form stesso risulti valido e come possiamo scriverci un nostro ValidationProvider qualora quello offerto da Microsoft non ci basti.

Alla prossima!