Blazor QuickGrid using a Remote DataSource

And thou shalt make for it a grating of network of brass; and upon the net shalt thou make four brazen rings in the four corners thereof.
וְעָשִׂיתָ לּוֹ מִכְבָּר מַעֲשֵׂה רֶשֶׁת נְחֹשֶׁת וְעָשִׂיתָ עַל-הָרֶשֶׁת אַרְבַּע טַבְּעֹת נְחֹשֶׁת עַל אַרְבַּע קְצוֹתָיו

I really had to stretch my imagination a bit searching for a reference to a grid in the Torah. The closest word I could find was רשת which usually relates to a network. Exodus 27:4 provided the quote above. I'm guessing that most Hebrew speaking coders use the English word "grid" but part of the reason I spend time on this blog is to expand my knowledge of Biblical Hebrew so there you go.

As of .NET 8, the new QuickGrid Blazor component is officially supported. This grid is intended as a simple, flexible grid for general purpose use. It lacks a lot of features like nested grids and drag to reorder columns that are found in some commercial grid components, but it supplies enough basic functionality to make it the grid of choice in a lot of applications.

While studying the documentation found here, I didn't see code for an end-to-end sample application that leveraged a remote datasource linked to a database. There is an example that leverages a publicly available API from the US FDA Food Enforcement database, but this API seems to lack a few features that I wanted to play with that would demonstrate data sorting and filtering in addition to pagination. As such, I decided to put together a simple demo project on my own that could demonstrate a number of the features you can leverage with this simple grid component.

Source for this example can be found at https://github.com/rlebowitz/Finaltouch.QuickGrid

The Microsoft documentation recommends leveraging the QuickGrid component's ItemProvider attribute. ItemProvider is simply a delegate for a callback method that GridItemsProvider type, where TGridItem is the type of data displayed in the grid. The callback you provide must have a parameter of type GridItemsProviderRequest which specifies the start index, maximum row count, and sort order of data to return. If the grid employs paging or virtualization, your data source also needs to provide a total item count as well.

The Backend

For purposes of this demonstration, I downloaded a public SQLite database containing census information on the most popular male and female baby names grouped by US state. I constructed a pretty simple REST API that retrieves some of the records based on metadata indicating a starting index value, the maximum number of rows to retrieve, the sort order of the data as well as certain kinds of filter data. The metadata class is seen below. The SortProperty enumeration is part of the QuickGrid component library. The Filter class is I created a simple repository class that processes metadata posted to an API controller and returns a result containing a list of records matching the filter criteria.

  
 public class GridMetaData
    {
        public int StartIndex { get; set; }
        public int? Count { get; set; }
        public ICollection? SortProperties { get; set; }
        public Filter? Filter { get; set; }
    }
    

    
    public class Filter
    {
        public string? Field { get; set; }
        public string? Value { get; set; }
        public Operator Operator { get; set; } = Operator.Equals;
        public bool IsValid => Field != null && Value != null;

    }


public enum Operator
    {
        Equals,
        NotEquals,
        GreaterThan,
        LessThan,
        Contains
    }

If the metadata syntax seems a bit odd, it's because I am using the System.Linq.Dynamic.Core library to create LINQ queries dynamically from string fragments. Dynamic LINQ is worth taking a look at if like me, you want to generate filters that can be changed by controls within the grid. I couple Dynamic LINQ with some IQueryable extension methods to make it easier to construct the necessary queries based on the filter criteria.

  
        public class NamesRepository : INamesRepository
    {
        private ILogger Logger { get; set; }
        private BabynamesContext Context { get; set; }

        public NamesRepository(ILogger logger, BabynamesContext context)
        {
            Logger = logger;
            Context = context;
        }

        public NamesResult? GetBabyNames([FromBody] GridMetaData metaData)
        {
            try
            {
                var ordering = Ordering(metaData.SortProperties);
                var ordered = string.IsNullOrEmpty(ordering)
                    ? Context.Babynames.OrderBy(t => t.State)
                    : Context.Babynames.OrderBy(ordering);
                var result = ordered
                    .Filter(metaData.Filter)
                    .Select(t => t)
                    .Skip(metaData.StartIndex)
                    .Take(metaData.Count ?? 10)
                    .AsNoTracking()
                    .ToListAsync();
                var count = Context.Babynames.AsQueryable().Filter(metaData.Filter).CountAsync();

                Task.WaitAll(count, result);

                return new NamesResult
                {
                    Count = count.Result,
                    BabyNames = result.Result
                };
            }
            catch (Exception ex)
            {
                Logger.LogError(ex, "Controller Error");
            }
            return default;
        }

        //https://dynamic-linq.net/basic-simple-query#ordering-results
        private static string Ordering(ICollection? properties)
        {
            List columns = new();
            if (properties == null)
            {
                return string.Empty;
            }
            foreach (var property in properties)
            {
                if (property.Direction == SortDirection.Ascending)
                {
                    columns.Add(property.PropertyName);
                }
                else
                {
                    columns.Add($"{property.PropertyName} desc");
                }
            }
            return string.Join(", ", columns);
        }
    }

The Frontend

As the Razor code sample below shows, I've specified a callback method that matches the GridItemsProvider delegate type called NamesProvider. I've also indicated that I will be using pagination to view data in small chunks one at a time, so I don't need to leverage the Virtualize attribute for QuickGrid. I've chosen to only display a few of the data fields provided by my Sqlite database in this demo application. I specify that all the PropertyColumns are sortable, and, in order to demonstrate how to implement a search function using Dynamic Linq, I've provided an input field that will act as a filter on the State data property.


@page "/"
@using Microsoft.AspNetCore.Components;
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.QuickGrid
@layout Shared.MainLayout

<PageTitle>Baby Names</PageTitle>
<div class="container py-3">
    <div class="row mb-4">
        <div class="col-lg-8 mx-auto text-center">
            <h1 class="display-6">Most Popular Baby Names by State</h1>
        </div>
    </div>

    <div class="row">
        <div class="col-lg-10 mx-auto">
            <div class="grid" tabindex="-1">
                <QuickGrid @ref="Grid" ItemsProvider="@NamesProvider" Class="comic-name" Theme="default" Virtualize="false" Pagination="@Pagination">
                    <PropertyColumn Title="State" Property="@(c => c.State)" Sortable="true" IsDefaultSortColumn="true">
                        <ColumnOptions>
                            <div class="search-box">
                                <input type="search" autofocus @bind="Filter" @bind:event="oninput" placeholder="State Name..." />
                            </div>
                        </ColumnOptions>
                    </PropertyColumn>
                    <PropertyColumn Title="Name" Property="@(c => c.Name)" Sortable="true" />
                    <PropertyColumn Title="Sex" Property="@(c => c.Sex)" Sortable="true" />
                    <PropertyColumn Title="Rank" Property="@(c => c.RankWithinSex)" Sortable="true" />
                </QuickGrid>
            </div>
            <Paginator State="@Pagination" />
        </div>
    </div>
</div>

The code-behind for this Razor page contains the method matching the delegate, and illustrates how data is posted to the REST service that provides sorting, filtering, etc. specifications at the same time. Note that each time that the Filter properties are updated, I call the QuickGrid's RefreshDataAsync method to let the grid know that it needs to pull back data that reflects the changes to the Filter.


public partial class Index
    {
        [Inject]
        private HttpClient Client { get; set; } = default!;
        private GridItemsProvider<Babyname>? NamesProvider { get; set; }

        private PaginationState Pagination = new PaginationState { ItemsPerPage = 10 };
        private QuickGrid<Babyname>? Grid { get; set; }
        private string? FilterText { get; set; }
        private string? Filter
        {
            get { return FilterText; }
            set
            {
                FilterText = value;
                if (Grid != null)
                {
                    Task.Run(Grid.RefreshDataAsync);
                }
            }
        }

        protected override void OnInitialized()
        {
            NamesProvider = Provider;
        }

        private async ValueTask<GridItemsProviderResult<Babyname>> Provider(GridItemsProviderRequest<Babyname> request)
        {
            GridItemsProviderResult<Babyname> result = default;
            var metaData = new GridMetaData
            {
                StartIndex = request.StartIndex,
                Count = request.Count,
                SortProperties = request.GetSortByProperties().ToArray(),
                Filter = new Filter { Field = "state", Operator = Operator.Contains, Value = FilterText },
            };
            var namesResult = await GetNames(metaData);
            if (namesResult != null)
            {
                result = new GridItemsProviderResult<Babyname>()
                {
                    Items = namesResult.BabyNames != null ? namesResult.BabyNames : Array.Empty<Babyname>(),
                    TotalItemCount = namesResult.Count
                };
            }
            return result;
        }

        public async Task<NamesResult?> GetNames(GridMetaData metaData)
        {
            var response = await Client.PostAsJsonAsync("api/BabyNames/GetBabyNames", metaData);
            if (response.IsSuccessStatusCode)
            {
                return await response.Content.ReadFromJsonAsync<NamesResult>();
            }
            return null;
        }
    }