Table of Contents

List Pages

Arbiter.Components provides three base components for displaying collections of models. Choose the one that matches how the data is loaded and displayed.

Base component Loads Use for
ListPageBase<TKey, TReadModel> One page at a time Paged lists where the read and list models are the same
ListPageBase<TKey, TReadModel, TListModel> One page at a time Paged lists with a separate, lighter list model
ResultPageBase<TReadModel> The full result set at once Search, charts, or export where the component handles paging in memory
NestedListPageBase<TKey, TParentModel, TReadModel, TListModel> A parent model plus a paged child list Related records displayed under a parent

Paged list

ListPageBase<TKey, TReadModel> loads pages through a DataGrid. The base component provides LoadData as the grid's DataProvider and DataComponent as the grid reference. The following example is from samples/EntityFramework/src/Tracker.Client/Pages/Priorities/List.razor:

@page "/priorities"

@inherits ListPageBase<int, PriorityReadModel>

<PageTitle>Priorities</PageTitle>

<DataGrid TItem="PriorityReadModel"
          DataProvider="LoadData"
          Filterable="true"
          Sortable="true"
          @ref="DataComponent">
    <DataColumns>
        <DataColumn Property="x => x.Name" Title="Name" Width="280px">
            <Template Context="item">
                <a href="@RouteLinks.Priorities.Edit(item.Id)"
                   title="Edit Priority @item.Name">
                    @item.Name
                </a>
            </Template>
        </DataColumn>

        <DataColumn Property="x => x.DisplayOrder" Title="Order" SortIndex="0" />
        <DataColumn Property="x => x.IsActive" Title="Active" />

        <DataColumn Property="x => x.Id" Title="Action" Filterable="false" Width="100px">
            <Template Context="item">
                <button type="button"
                        class="btn btn-sm"
                        title="Delete Priority @item.Name"
                        @onclick="() => HandleDelete(item)">
                    <i class="bi bi-trash3"></i>
                </button>
            </Template>
        </DataColumn>
    </DataColumns>
    <DataPagination Context="grid">
        <DataPager PageSize="10" />
        <DataSizer />
        <div>@grid.Pager.StartItem - @grid.Pager.EndItem of @grid.Pager.Total</div>
    </DataPagination>
</DataGrid>
Member Description
LoadData The DataProvider the DataGrid calls for each page request
DataComponent The DataGrid reference wired with @ref
DataGrid Convenience accessor for the underlying grid
HandleDelete(item) Deletes the given row

Default query and filters

Override CreateDefaultQuery() to seed the grid with filters — for example, from query string parameters. This example is from samples/EntityFramework/src/Tracker.Client/Pages/Tasks/List.razor:

@page "/tasks"

@inherits ListPageBase<int, TaskReadModel>

<DataGrid TItem="TaskReadModel"
          DataProvider="LoadData"
          Query="@DefaultQuery"
          Filterable="true"
          Sortable="true"
          @ref="DataComponent">
    @* columns *@
</DataGrid>

@code
{
    [SupplyParameterFromQuery(Name = "priority")]
    private int? PriorityId { get; set; }

    protected override QueryRule? CreateDefaultQuery()
    {
        if (!PriorityId.HasValue)
            return null;

        var filter = new QueryFilter
        {
            Field = nameof(TaskReadModel.PriorityId),
            Operator = QueryOperators.Equal,
            Value = PriorityId.Value
        };

        return new QueryGroup
        {
            Id = nameof(CreateDefaultQuery),
            Logic = QueryLogic.And,
            Filters = [filter]
        };
    }
}

Filters can also be applied dynamically at runtime by calling DataGrid.ApplyFilter and DataGrid.RemoveFilter from input change handlers, allowing custom toolbar controls such as date ranges and lookups to drive the query.

Adding a fixed constraint

CreateDefaultQuery seeds a filter the user can still change, whereas CombineFilter(EntityFilter?) adds a constraint the user cannot remove. The base LoadData calls it on every page request, after the grid state has been converted to a filter and before the query is sent:

var query = request.ToQuery();
query.Filter = CombineFilter(query.Filter);

Override CombineFilter to combine the grid's filter with the constraint, using FilterLogic.And so the user's own grid filters are preserved. This example always restricts the list to active records:

protected override EntityFilter? CombineFilter(EntityFilter? gridFilter)
{
    var requiredFilter = new EntityFilter
    {
        Name = nameof(PriorityReadModel.IsActive),
        Operator = FilterOperators.Equal,
        Value = true
    };

    if (gridFilter == null)
        return requiredFilter;

    return new EntityFilter
    {
        Logic = FilterLogic.And,
        Filters = [requiredFilter, gridFilter]
    };
}

Returning gridFilter unchanged leaves the grid in control of the filter, while discarding it also discards whatever the user typed into the grid filter row — so combine both. The nested list uses the same method to restrict a child list to its parent record.

Loading additional data

Override OnLoadedAsync(CancellationToken) to load supporting data after the grid finishes refreshing. It runs after every refresh, once the component has rendered its items, so a failure is logged and reported but does not affect the data already displayed:

protected override async Task OnLoadedAsync(CancellationToken cancellationToken)
{
    await base.OnLoadedAsync(cancellationToken);

    // load supporting data using Dispatcher or DataService
}

Separate list model

ListPageBase<TKey, TReadModel, TListModel> uses a distinct TListModel for the grid, which is useful when the list view needs only a subset of fields or denormalized display values. The page inherits with three type parameters and binds the grid to the list model:

@page "/banks"

@inherits ListPageBase<int, BankConfigurationReadModel, BankConfigurationListModel>

<DataGrid TItem="BankConfigurationListModel"
          DataProvider="LoadData"
          Filterable="true"
          Sortable="true"
          StateKey="BankConfigurationGrid"
          @ref="DataComponent">
    @* columns bound to BankConfigurationListModel *@
</DataGrid>

No custom code is required; LoadData loads the TListModel page through the dispatcher.

Full result set

ResultPageBase<TReadModel> loads the entire result set at once and lets the component handle paging, sorting, and filtering in memory. It is a good fit for search pages, charts, and exports. The base component exposes DataLoader as the grid's loader, Data as the loaded set, and RefreshData() to reload. This example is from samples/EntityFramework/src/Tracker.Client/Pages/Tasks/Search.razor:

@page "/tasks/search"

@using Arbiter.CommandQuery.Queries

@inherits ResultPageBase<TaskReadModel>

<input type="search"
       class="form-control"
       placeholder="Search Tasks"
       value="@SearchText"
       @oninput="HandleSearchChanged" />

<DataGrid TItem="TaskReadModel"
          DataLoader="DataLoader"
          Filterable="false"
          Sortable="true"
          @ref="DataComponent">
    @* columns *@
</DataGrid>

<div class="card-footer">
    @Data.Count Task(s) found
</div>

@code
{
    private string? SearchText { get; set; }

    protected override EntityQuery? CreateEntityQuery()
    {
        if (string.IsNullOrWhiteSpace(SearchText))
            return null;

        return EntityFilterBuilder.CreateSearchQuery<TaskReadModel>(SearchText, pageSize: 100);
    }

    private async Task HandleSearchChanged(ChangeEventArgs args)
    {
        SearchText = args.Value as string;
        await RefreshData();
    }
}
Member Description
DataLoader The loader the DataGrid or DataList calls to load the full set
Data The loaded, read-only result set
CreateEntityQuery() Override to build the query for the result set
RefreshData() Reloads the result set with the current query

Nested list

NestedListPageBase<TKey, TParentModel, TReadModel, TListModel> displays a paged list of related records under a parent model. The Id route parameter identifies the parent, which the base component loads before loading the child list. The following example lists the devices for a bank:

@page "/banks/{id:int}/devices"

@inherits NestedListPageBase<int, BankConfigurationReadModel, DeviceConfigurationReadModel, DeviceConfigurationListModel>

<PageTitle>@PageTitle("Devices")</PageTitle>

<DataGrid TItem="DeviceConfigurationListModel"
          DataProvider="LoadData"
          Filterable="true"
          Sortable="true"
          @ref="DataComponent">
    <DataColumns>
        <DataColumn Property="x => x.DeviceId" Title="Device Id" SortIndex="0" />
        <DataColumn Property="x => x.Description" Title="Description" />
        <DataColumn Property="x => x.IsActive" Title="Active" Width="80px" />
    </DataColumns>
</DataGrid>

Restricting the list to the parent

The base component does not know how the child records relate to the parent, so the page restricts the list by overriding CombineFilter(EntityFilter?). This method is called on every page request, after the grid state has been converted to a filter and before the query is sent. Build a parent filter from Id and combine it with the grid's filter using FilterLogic.And so the user's own grid filters are preserved:

@code
{
    protected override EntityFilter? CombineFilter(EntityFilter? gridFilter)
    {
        var parentFilter = new EntityFilter
        {
            Name = "DeviceConfigurationBanks.Any(it.BankConfigurationId in @0)",
            Operator = FilterOperators.Expression,
            Value = new[] { Id }
        };

        if (gridFilter == null)
            return parentFilter;

        return new EntityFilter
        {
            Logic = FilterLogic.And,
            Filters = [parentFilter, gridFilter]
        };
    }
}

When the relationship is a direct foreign key on the child record, the parent filter is a simple field equality instead:

var parentFilter = new EntityFilter
{
    Name = nameof(DeviceConfigurationListModel.BankConfigurationId),
    Operator = FilterOperators.Equal,
    Value = Id
};

Returning gridFilter unchanged would leave the list unfiltered by the parent, while discarding it would also drop whatever the user typed into the grid filter row — so combine both. A two-parameter convenience overload, NestedListPageBase<TKey, TParentModel, TListModel>, is available when the read and list models are the same.

Next steps