Articoli

Componenti Generici in Blazor – Parte 1

da

Data pubblicazione: 17-05-2020 – Autore: Michele Aponte

Nella serie di articoli base su come creare una Single Page Application abbiamo visto come generalizzare un componente attraverso i parametri, ma per poterli riutilizzare in contesti differenti è necessario spingersi un po’ più in là con l’astrazione. Se vogliamo ad esempio generalizzare la griglia che abbiamo creato, dobbiamo poter passare una collezione di oggetti qualsiasi in modo da poterla riutilizzare in qualsiasi contesto.

A tale scopo Blazor offre vari strumenti di cui oggi analizziamo il più semplice, che in realtà non è offerto da Blazor nello specifico, ma dalla possibilità di poter usare la Reflection di .NET Core anche in WebAssembly.

Creare una griglia generica

Partiamo dal progetto dell’articolo su come creare un libreria di componenti, che abbiamo poi esteso con l’uso della Dependency Injection. La pagina FetchData mostra una tabella con intestazioni fisse e righe basate sul tipo WeatherForecast: estraiamo un componente griglia da questo codice e generalizziamolo.

Nel progetto LibreriaComponenti, creiamo un cartella Components (ricordatevi di aggiungere la using nel file _Imports.cs) con dentro un file Grid.razor e copiamo al suo interno la <table> della pagina FetchData. Aggiungiamo poi alla cartella Models una nuova classe C# che chiamaremo GridModel, così definita:

public class GridModel
{
    public string[] Columns { get; set; }
    public object[] Rows { get; set; }
}

Questo oggetto ci servirà per poter specificare le intestazioni di colonna che vogliamo visualizzare e le righe, che come potete notare sono modellate come array di object. Utilizzando questo modello come tipo per il parametro del componente griglia, possiamo sfruttare la Reflection di .NET Core per recuperare i valori delle proprietà dagli oggetti:

<table class="table">
    <thead>
        <tr>
            @foreach (var col in Model.Columns)
            {
                <th>@col</th>
            }
        </tr>
    </thead>
    <tbody>
        @foreach (var row in Model.Rows)
        {
            <tr>
                @foreach (var prop in row.GetType().GetProperties())
                {
                    <td>@prop.GetValue(row)</td>   
                }
            </tr>
        }
    </tbody>
</table>
@code 
{
    [Parameter] 
    public GridModel Model { get; set; }
}

La pagina Fetch a questo punto deve istanziare il nostro modello, definire le intestazioni di colonna e recuperare le righe come già faceva:

@page "/fetchdata"
@inject IWeatherForecastService WeatherForecastService

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (WeatherForecastModel.Rows == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <Grid Model="WeatherForecastModel" />
}
@code 
{
    private GridModel WeatherForecastModel = new GridModel();

    protected override async Task OnInitializedAsync()
    {
        WeatherForecastModel.Columns = new [] { "Date", "Temp. (C)", "Temp. (F)", "Summary" };
        WeatherForecastModel.Rows = await WeatherForecastService.GetForecastAsync(DateTime.Now);
    }
}

Eseguendo il codice otteremo il seguente risultato:

Notiamo subito che la data non è formattata, come invece lo era nella griglia originale, che utilizzava il metodo forecast.Date.ToShortDateString() durante il rendering del valore. Modifichiamo quindi la definizione delle colonne per poter specificare anche la formattazione:

public class GridModel
{
    public GridColumn[] Columns { get; set; }
    public object[] Rows { get; set; }
}

public class GridColumn
{
    public string Caption { get; set; }
    public string Format { get; set; }
}

Il codice della pagina Fetch diventa:

@code 
{
    private GridModel WeatherForecastModel = new GridModel();

    protected override async Task OnInitializedAsync()
    {
        WeatherForecastModel.Columns = new GridColumn [] 
            { 
                new GridColumn { Caption = "Date", Format = "{0:d}" },
                new GridColumn { Caption = "Temp. (C)" },
                new GridColumn { Caption = "Temp. (F)" }, 
                new GridColumn { Caption = "Summary" }
            };
        WeatherForecastModel.Rows = await WeatherForecastService.GetForecastAsync(DateTime.Now);
    }
}

A questo punto trasformiamo il foreach dei valori delle righe in un for e utilizziamo l’indice per accedere alla colonna e recuperare il formato da applicare (se c’è):

<table class="table">
    <thead>
        <tr>
            @foreach (var col in Model.Columns)
            {
                <th>@col.Caption</th>
            }
        </tr>
    </thead>
    <tbody>
        @foreach (var row in Model.Rows)
        {
            var properties = row.GetType().GetProperties();
            <tr>  
                @for (int i = 0; i < properties.Length; i++)
                {
                    @if(string.IsNullOrEmpty(Model.Columns[i].Format))
                    {
                        <td>@properties[i].GetValue(row)</td>
                    }
                    else
                    {
                        <td>@String.Format(
                            Model.Columns[i].Format,
                            properties[i].GetValue(row))</td>
                    }
                }
            </tr>
        }
    </tbody>
</table>

In questo modo possiamo formattare le nostre colonne:

Nella definizione della colonna possiamo a questo punto mettere tutte le informazioni che vogliamo, ad esempio un valore che ci dice se la colonna è ordinabile, se è visibile o se magari vogliamo quale stile particolare.

Abbiamo però dato per scontato che il modello che stiamo usando sia esattamente coincidente con quello che vogliamo visualizzare, cosa che dovrebbe essere vera se l’oggetto utilizzato è un ViewModel, una classe (Model) cioè pensata ad uso e consumo della nostra Pagina (View). Abbiamo anche assunto che l’ordine con cui il metodo row.GetType().GetProperties() ci restituisce le proprietà coincida con l’ordine di definizione delle righe.

A questo punto l’idea potrebbe essere: se ho creato effettivamente un ViewModel a uso e consumo della mia pagina, perchè non sfruttarlo anche per fornire i metadati di visualizzazione della griglia?

Usare le annotazioni per i metadati

Come programmatori .NET sappiamo bene che il nostro framework ha gli attributi! Sì, anche in quel senso, ma in questo caso intendo letteralmente: possiamo creare delle classi da applicare come attributi alle proprietà per definirne un aspetto esterno, senza doverle implementare nel codice della proprietà stessa. Per farlo basta creare una classe che estenda la classe base System.Attribute, ma nel nostro caso meglio non reinvetare la ruota visto che abbiamo già le DataAnnotation che usiamo con ASP.NET MVC.

Aggiungiamo quindi al nostro modello le annotazioni che ci servono:

using System;
using System.ComponentModel.DataAnnotations;

namespace LibreriaComponenti.Models
{
    public class WeatherForecast
    {
        [DisplayFormat(DataFormatString="{0:d}")]
        public DateTime Date { get; set; }

        [Display(ShortName = "Temp. (C)")]
        public int TemperatureC { get; set; }

        [Display(ShortName = "Temp. (F)")]
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
        public string Summary { get; set; }
    }
}

Assumiamo che nel caso non sia stato specificato l’attributo Display, debba essere utilizzato il nome della proprietà come intestazione di colonna. Per mantenere entrambe le implementazioni che abbiamo fatto, aggiungiamo un valore booleano a GridModel dove specificare se usare o no le annotazioni:

public class GridModel
{
    public GridColumn[] Columns { get; set; }
    public object[] Rows { get; set; }
    public bool UseAnnotations { get; set; } 
}

Semplifichiamo il markup del nostro componente spostando l’utilizzo della reflection nella sezione di codice, riempiendo due array di appoggio per le intestazioni di colonne e i valori delle righe:

@using System.Reflection
@using System.ComponentModel.DataAnnotations
<table class="table">
    <thead>
        <tr>
            @foreach (var col in columns)
            {
                <th>@col</th>
            }
        </tr>
    </thead>
    <tbody>
        @for(int row = 0; row < rows.GetLength(0); row++)
        {
            <tr>
            @for(int col = 0; col < rows.GetLength(1); col++)
            {
                <td>@rows[row, col]</td>
            }
            </tr>
        }
    </tbody>
</table>
@code 
{
    [Parameter] 
    public GridModel Model { get; set; }

    private string[] columns;
    private string[] displayFormat;
    private string[,] rows;


    protected override void OnParametersSet()
    {
        var properties = Model.Rows.GetType().GetElementType().GetProperties();
        loadColumns(properties);
        loadRows(properties);            
    }

    private void loadColumns(PropertyInfo[] properties)
    {
        columns = new string[properties.Length];
        displayFormat = new string[properties.Length];
        if(Model.UseAnnotations)
        {
            for (int col = 0; col < properties.Length; col++)
            {  
                var display = properties[col]
                    .GetCustomAttributes(typeof(DisplayAttribute), false)
                    .FirstOrDefault() as DisplayAttribute;

                var format = properties[col]
                    .GetCustomAttributes(typeof(DisplayFormatAttribute), false)
                    .FirstOrDefault() as DisplayFormatAttribute;

                columns[col] = display == null ? properties[col].Name : display.ShortName;
                displayFormat[col] = format != null ? format.DataFormatString  : null;
            }
        }
        else
        {
            columns = Model.Columns.Select(x => x.Caption).ToArray();
            displayFormat = Model.Columns.Select(x => x.Format).ToArray();
        }
    }

    private void loadRows(PropertyInfo[] properties)
    {
        rows = new string[Model.Rows.Length, properties.Length];

        for (int row = 0; row < Model.Rows.Length; row++)
        {
            for(int col = 0; col < properties.Length; col++)
            {
                rows[row, col] = displayFormat[col] == null 
                    ? properties[col].GetValue(Model.Rows[row]).ToString()
                    : String.Format(displayFormat[col], properties[col].GetValue(Model.Rows[row]));
            }
        }
    }
}

Come potete vedere dal codice sono stati aggiunti due metodi, loadColumns() e loadRows, invocati nel metodo OnParametersSet() che, come ricorderete, viene richiamato ogni volta che un parametro viene impostato. Con l’istruzione Model.Rows.GetType().GetElementType().GetProperties() recuperiamo le proprietà del tipo contenuto nell’array, che poi passiamo ai metodi di caricamento. Durante il caricamento delle colonne, se è stato richiesto l’uso delle Data Annotations per recuperare i metadati, utilizziamo il metodo GetCustomAttributes(Type attributeType, bool inherit) per recuperare gli attributi custom, che nel nostro caso sono DisplayFormatAttribute per le intestazioni di colonna e DisplayFormatAttribute per il formato dei valori. Il tutto viene poi messo in variabili di appoggio che utilizziamo nel markup.

Potete scaricare il codice e provarlo voi stessi dal repository della community: provare per credere!

Conclusioni

In questo articolo abbiamo visto come poter generalizzare un componente griglia, sfruttando la Reflection di .NET Core e un array di tipo object. Nei prossimi articoli ci spingeremo più in là, utilizzando i Generics e capendo come sia possibile proiettare del markup all’interno dei nostri componenti.

Scritto da: