Loading data
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:
<Table TItem="Order" ItemsProvider="LoadOrders" PageSize="10">...</Table>
{
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:
<PropertyColumn Property="o => o.Tags" Filterable Filter="TagFilter" />
{
// 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.
// 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.
<Table TItem="AuditEvent" ItemsProvider="LoadEvents" PageSize="200" Paging="DataPaging.NextPrevious">...</Table>
{
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
Code
@* 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>
{
// 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
| Time (UTC) | Who | Action |
|---|---|---|
| Oct 27, 08:23 | katherine | Changed a rule |
| Oct 27, 07:46 | grace | Exported a report |
| Oct 27, 07:09 | margaret | Invited a member |
| Oct 27, 06:32 | alan | Rotated a key |
| Oct 27, 05:55 | ada | Signed out |
| Oct 27, 05:18 | katherine | Signed in |
| Oct 27, 04:41 | grace | Changed a rule |
| Oct 27, 04:04 | margaret | Exported a report |
Code
@* 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>
{
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
Code
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>
{
[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.