Skip to content
IzzyBlazor 0.1.0-alpha.20

Enter all results ↑ ↓ move Esc close

Loading data

Rows from a database or an API, paging by cursor, loading on scroll, and a failed load.

Static server-side rendering: no circuit, no WebAssembly.

Data from a database

Pass an EF Core IQueryable as Items, and the database sorts, filters and pages. For more control, use ItemsProvider to load the rows yourself. It gets a DataRequest (the range as Skip and Take, the Sort, the Filter, and the columns, which say how each key reads an item) and returns a DataResult. ToResultAsync counts what the filter keeps and lists the page, through EF Core's async methods:

razor
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="10">...</Table>

@code {
    async ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request)
    {
        var query = db.Orders.AsNoTracking().Where(o => o.Active);
        return await request.ToResultAsync(query,             // filters, sorts, pages
            count: (q, ct) => q.CountAsync(ct),
            list: (q, ct) => q.ToListAsync(ct));
    }
}

The filter becomes a LINQ expression on each column's own property, so the database filters, and a value typed into a filter never becomes code. request.Apply(query) does the same without counting; ApplyFiltering and ApplySorting do one part each. To filter a column your own way, such as a value stored differently from how it's shown, give it a Filter that returns the condition:

razor
<PropertyColumn Property="o => o.Tags" Filterable Filter="TagFilter" />

@code {
    // Tags are stored lower case, as "red;green": "Green" in the filter finds it.
    static Expression<Func<Order, bool>>? TagFilter(FieldFilter f) =>
        f.Operator == FilterOperator.Contains && f.Value is string tag
            ? o => (";" + o.Tags + ";").Contains(";" + tag.ToLower() + ";")
            : null;
}

Data from an API

When the rows come from an API, pass the request on. request.Filter?.Find("status") reads one column's condition, and request.ToQueryString() writes the whole request (sort=-date&skip=20&take=10&filter=total ge 100). The API reads it back with DataRequest<Order>.Parse and applies it through the fields it declares, so a request can't reach other properties. ToODataQuery() writes it for an OData endpoint, and the request also serializes with System.Text.Json. Data and filters has the operators, the filter syntax and a full client and server.

DataFields.Of<T>() declares every public property, so read it from a type made for the API, or leave a property out with [ScaffoldColumn(false)]. The request asks for its own Take, so cap it on the server if it shouldn't ask for every row.

csharp
// In the browser (WebAssembly): the provider calls the API with the request.
async ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request) =>
    (await http.GetFromJsonAsync<DataResult<Order>>(
        $"api/orders?{request.ToQueryString()}", request.CancellationToken))!;

// On the server: only the fields declared here can be filtered or sorted.
app.MapGet("/api/orders", async (HttpContext http, ShopDb db) =>
{
    var request = DataRequest<Order>.Parse(http.Request.QueryString.Value ?? "")
        with { CancellationToken = http.RequestAborted };
    request = request with { Take = Math.Min(request.Take ?? 100, 100) };   // at most 100 rows
    return await request.ToResultAsync(db.Orders.AsNoTracking(), DataFields.Of<Order>(),
        count: (q, ct) => q.CountAsync(ct), list: (q, ct) => q.ToListAsync(ct));
});

Paging without a total

When the data pages by cursor (an opaque "next" token, as many APIs and logs do) or has no total, set Paging and return the next cursor from your ItemsProvider. The table passes it back as request.Cursor; the total is optional.

razor
<Table TItem="AuditEvent" ItemsProvider="LoadEvents" PageSize="200" Paging="DataPaging.NextPrevious">...</Table>

@code {
    async ValueTask<DataResult<AuditEvent>> LoadEvents(DataRequest<AuditEvent> request)
    {
        var page = await api.ListEventsAsync(cursor: request.Cursor, limit: request.Take ?? 200);
        return new(page.Items) { Next = page.Next };   // no total needed
    }
}

DataPaging.NextPrevious shows First, Previous and Next. Return Previous too if your source has it; without it, interactive tables go back through the cursors they've followed, and static ones offer First.

Preview

Orders by cursor
OrderCustomer Date Total
1007Ada LovelaceOct 20$316.75
1014Ada LovelaceOct 19$149.00
1021Ada LovelaceOct 18$441.25
1006Alan TuringOct 13$275.00
1013Alan TuringOct 12$107.25

Code

CursorPages.razor
@* First, Previous and Next by cursor, for a source that gives both cursors and a total. The cursor
   is opaque to the table; a new sort starts again from the first page. *@
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="5" Paging="DataPaging.NextPrevious" InitialSort="-date"
       Caption="Orders by cursor" QueryPrefix="cursor-" Grow>
    <PropertyColumn Property="o => o.Number" Title="Order" Align="CellAlign.Start" />
    <PropertyColumn Property="o => o.Customer" Sortable />
    <PropertyColumn Property="o => o.Date" Format="MMM d" Sortable />
    <PropertyColumn Property="o => o.Total" Format="C" Sortable />
</Table>

@code {
    // Here the cursor is an offset in base64; a real API's would be its own token.
    private static ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request)
    {
        var size = request.Take ?? 5;
        var at = Decode(request.Cursor);
        var query = request.ApplySorting(request.ApplyFiltering(Orders.All.AsQueryable()));
        var total = query.Count();
        return ValueTask.FromResult(new DataResult<Order>(query.Skip(at).Take(size).ToList(), total)
        {
            Next = at + size < total ? Encode(at + size) : null,
            Previous = at > 0 ? Encode(Math.Max(0, at - size)) : null,
        });
    }

    private static string Encode(int offset) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(offset.ToString()));

    // A cursor this source didn't make reads as the first page.
    private static int Decode(string? cursor)
    {
        Span<byte> bytes = stackalloc byte[32];
        return cursor is not null && Convert.TryFromBase64String(cursor, bytes, out var length)
            && int.TryParse(bytes[..length], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var at) ? at : 0;
    }
}

Static: The same links; enhanced navigation swaps the page in.

Loading on scroll

DataPaging.LoadOnScroll loads the next page as the last row comes into view and adds it under the rows: in place on interactive pages, and on static pages by following its Load more link, which enhanced navigation swaps in with the scroll kept. Give the table a MaxHeight, so it scrolls in its own box and what's under it stays within reach. Its Load more button, under the last row, is for the keyboard, out of sight until it has focus (LoadMoreText sets its text); with JavaScript off it shows, as a link that reloads the table with one more page of rows (?page=3 shows the first three, asked of the provider as one request). With an ItemKey, a row already shown isn't added twice. A parent's re-render keeps the added pages; call ReloadAsync() to start over. Both modes also work with Items, where the table pages by row offset. (A visible Load more button is a list's pattern: see ListView.)

Preview

Audit log, loading on scroll
Time (UTC)WhoAction
Oct 27, 08:23katherineChanged a rule
Oct 27, 07:46graceExported a report
Oct 27, 07:09margaretInvited a member
Oct 27, 06:32alanRotated a key
Oct 27, 05:55adaSigned out
Oct 27, 05:18katherineSigned in
Oct 27, 04:41graceChanged a rule
Oct 27, 04:04margaretExported a report
1–8

Code

LoadOnScroll.razor
@* Loading as you scroll: the next page loads when the last row comes into view in the table's own
   scroll box (MaxHeight), with a spinner under the rows while it does. The Load more button is
   there for the keyboard, out of sight until it has focus. *@
<Table TItem="AuditEvent" ItemsProvider="LoadEvents" PageSize="8" Paging="DataPaging.LoadOnScroll" MaxHeight="Length.Rem(18)"
       ItemKey="e => e.Id.ToString()" Caption="Audit log, loading on scroll" QueryPrefix="scroll-" Grow>
    <PropertyColumn Property="e => e.At" Title="Time (UTC)" Format="MMM d, HH:mm" />
    <PropertyColumn Property="e => e.Who" />
    <PropertyColumn Property="e => e.What" Title="Action" />
</Table>

@code {
    private static async ValueTask<DataResult<AuditEvent>> LoadEvents(DataRequest<AuditEvent> request)
    {
        var (items, next) = await Events.ListAsync(request.Cursor, request.Take ?? 8);
        return new DataResult<AuditEvent>(items) { Next = next };
    }
}

Static: Scroll the rows: the next page loads as the last one comes into view, by the Load more link, which enhanced navigation swaps in with the scroll kept. Tab to the hidden button to load from the keyboard.

When loading fails

If the ItemsProvider throws (or an IQueryable in Items does), the table logs the exception and shows "Couldn't load the rows." with Retry in place of the rows. On static pages and with JavaScript off, Retry is a link to the same address. ErrorContent replaces the message. When loading on scroll, the rows already shown stay, and the message and Retry go under them. A request the table canceled because a newer one replaced it isn't an error: pass request.CancellationToken on to stop the work.

Preview

The orders API is down. Bring it back

Couldn't load the rows.
Orders from a flaky API
OrderCustomer Date Total
Couldn't load the rows. Retry

Code

LoadError.razor
@inject NavigationManager Navigation

@* When loading fails: the provider throws, and the table shows "Couldn't load the rows." with
   Retry in place of the rows (and logs the exception). The link above it takes this stand-in API
   down or brings it back (?flaky-api=), so Retry can fail or succeed. *@
<Stack Gap="3" Grow>
    <TextBlock Size="TextSize.Sm">
        @(Down ? "The orders API is down." : "The orders API is up.")
        <Anchor Href="@Navigation.GetUriWithQueryParameter("flaky-api", Down ? "up" : null)">@(Down ? "Bring it back" : "Take it down")</Anchor>
    </TextBlock>
    <Table TItem="Order" ItemsProvider="LoadOrders" PageSize="5" InitialSort="-date" Caption="Orders from a flaky API" QueryPrefix="flaky-">
        <PropertyColumn Property="o => o.Number" Title="Order" Align="CellAlign.Start" />
        <PropertyColumn Property="o => o.Customer" Sortable />
        <PropertyColumn Property="o => o.Date" Format="MMM d" Sortable />
        <PropertyColumn Property="o => o.Total" Format="C" Sortable />
    </Table>
</Stack>

@code {
    [SupplyParameterFromQuery(Name = "flaky-api")] private string? Api { get; set; }

    private bool Down => Api != "up";

    private async ValueTask<DataResult<Order>> LoadOrders(DataRequest<Order> request)
    {
        await Task.Delay(300, request.CancellationToken); // a network call
        if (Down)
        {
            throw new HttpRequestException("The orders API didn't answer.");
        }

        return request.ToResult(Orders.All.AsQueryable());
    }
}

Static: The same link; enhanced navigation swaps the page in.

Connection lost

Rejoining the server…