From 8223568ed6e7af83132c0393aee52e658560fbba Mon Sep 17 00:00:00 2001
From: James Chapman <jchapman3000@gmail.com>
Date: Sun, 10 Dec 2023 20:36:48 +0000
Subject: [PATCH] Refactor tables to Razor components

---
 Groceries/Components/Align.cs                 |   8 +
 Groceries/Components/DataSort.cs              |  49 +++++
 Groceries/Components/PaginationState.cs       |  27 +++
 Groceries/Components/PropertyTableColumn.cs   |  71 ++++++
 Groceries/Components/Table.razor              | 207 ++++++++++++++++++
 Groceries/Components/TableColumn.cs           |  36 +++
 Groceries/Components/TablePaginator.razor     |  28 ++-
 Groceries/Components/TemplateTableColumn.cs   |  20 ++
 Groceries/Items/ItemsPage.razor               | 108 ++++-----
 Groceries/ListPageModel.cs                    |  88 --------
 Groceries/Stores/StoresPage.razor             |  91 ++++----
 .../NewTransactionItemsPage.razor             |  73 +++---
 .../NewTransactionPromotionsPage.razor        |  64 +++---
 Groceries/Transactions/TransactionsPage.razor | 149 +++----------
 Groceries/wwwroot/css/main.css                |  22 +-
 15 files changed, 624 insertions(+), 417 deletions(-)
 create mode 100644 Groceries/Components/Align.cs
 create mode 100644 Groceries/Components/DataSort.cs
 create mode 100644 Groceries/Components/PaginationState.cs
 create mode 100644 Groceries/Components/PropertyTableColumn.cs
 create mode 100644 Groceries/Components/Table.razor
 create mode 100644 Groceries/Components/TableColumn.cs
 create mode 100644 Groceries/Components/TemplateTableColumn.cs
 delete mode 100644 Groceries/ListPageModel.cs

diff --git a/Groceries/Components/Align.cs b/Groceries/Components/Align.cs
new file mode 100644
index 0000000..3cc516a
--- /dev/null
+++ b/Groceries/Components/Align.cs
@@ -0,0 +1,8 @@
+namespace Groceries.Components;
+
+public enum Align
+{
+    Start,
+    Center,
+    End,
+}
diff --git a/Groceries/Components/DataSort.cs b/Groceries/Components/DataSort.cs
new file mode 100644
index 0000000..b6a0d5b
--- /dev/null
+++ b/Groceries/Components/DataSort.cs
@@ -0,0 +1,49 @@
+namespace Groceries.Components;
+
+using System.Linq.Expressions;
+
+public static class DataSort
+{
+    public static DataSort<TItem> By<TItem, TResult>(Expression<Func<TItem, TResult>> expression, string key)
+        => new((source, desc) => desc ? source.OrderByDescending(expression) : source.OrderBy(expression), key);
+
+    public static DataSort<TItem> ByDescending<TItem, TResult>(Expression<Func<TItem, TResult>> expression, string key)
+        => new((source, desc) => desc ? source.OrderBy(expression) : source.OrderByDescending(expression), key);
+}
+
+public class DataSort<TItem>
+{
+    private readonly Func<IQueryable<TItem>, bool, IOrderedQueryable<TItem>> first;
+    private readonly List<Func<IOrderedQueryable<TItem>, bool, IOrderedQueryable<TItem>>> thens = [];
+
+    internal DataSort(Func<IQueryable<TItem>, bool, IOrderedQueryable<TItem>> first, string key)
+    {
+        this.first = first;
+        Key = key;
+    }
+
+    public string Key { get; }
+
+    public DataSort<TItem> ThenBy<TResult>(Expression<Func<TItem, TResult>> expression)
+    {
+        thens.Add((source, desc) => desc ? source.ThenByDescending(expression) : source.ThenBy(expression));
+        return this;
+    }
+
+    public DataSort<TItem> ThenByDescending<TResult>(Expression<Func<TItem, TResult>> expression)
+    {
+        thens.Add((source, desc) => desc ? source.ThenBy(expression) : source.ThenByDescending(expression));
+        return this;
+    }
+
+    internal IOrderedQueryable<TItem> Apply(IQueryable<TItem> source, bool descending)
+    {
+        var ordered = first(source, descending);
+        foreach (var then in thens)
+        {
+            ordered = then(ordered, descending);
+        }
+
+        return ordered;
+    }
+}
diff --git a/Groceries/Components/PaginationState.cs b/Groceries/Components/PaginationState.cs
new file mode 100644
index 0000000..834af7a
--- /dev/null
+++ b/Groceries/Components/PaginationState.cs
@@ -0,0 +1,27 @@
+namespace Groceries.Components;
+
+public class PaginationState
+{
+    private int itemCount;
+
+    public int PageSize { get; set; } = 10;
+    public int CurrentPage { get; internal set; }
+    public int TotalItemCount { get; internal set; }
+    public int ItemCount
+    {
+        get => itemCount;
+        internal set
+        {
+            itemCount = value;
+            ItemCountChanged?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    public int Offset => (CurrentPage - 1) * PageSize;
+    public int LastPage => ((TotalItemCount - 1) / PageSize) + 1;
+
+    internal event EventHandler? ItemCountChanged;
+
+    public override int GetHashCode()
+        => HashCode.Combine(PageSize, CurrentPage, ItemCount, TotalItemCount);
+}
diff --git a/Groceries/Components/PropertyTableColumn.cs b/Groceries/Components/PropertyTableColumn.cs
new file mode 100644
index 0000000..b4d8ae7
--- /dev/null
+++ b/Groceries/Components/PropertyTableColumn.cs
@@ -0,0 +1,71 @@
+namespace Groceries.Components;
+
+using Humanizer;
+using Microsoft.AspNetCore.Components;
+using System.Linq.Expressions;
+
+public class PropertyTableColumn<TItem, TProp> : TableColumn<TItem>
+{
+    private Expression<Func<TItem, TProp>>? lastAssignedProperty;
+    private Func<TItem, TProp>? compiledPropertyExpression;
+    private Func<TItem, string?>? cellTextFunc;
+    private DataSort<TItem>? sortBy;
+
+    [Parameter, EditorRequired]
+    public required Expression<Func<TItem, TProp>> Property { get; set; }
+
+    [Parameter]
+    public RenderFragment<TProp>? ChildContent { get; set; }
+
+    [Parameter]
+    public string? Format { get; set; }
+
+    [Parameter]
+    public override bool Sortable { get; set; }
+
+    public override DataSort<TItem>? SortBy
+    {
+        get => sortBy;
+        set => throw new NotSupportedException();
+    }
+
+    protected internal override RenderFragment<TItem> CellContent
+        => ChildContent != null
+            ? item => ChildContent(compiledPropertyExpression!(item))
+            : item => builder => builder.AddContent(0, cellTextFunc?.Invoke(item));
+
+    protected override void OnParametersSet()
+    {
+        if (Title is null && Property.Body is MemberExpression memberExpression)
+        {
+            Title = memberExpression.Member.Name;
+        }
+        if (Align is null && (typeof(TProp) == typeof(int) || typeof(TProp) == typeof(decimal)))
+        {
+            Align = Components.Align.End;
+        }
+
+        if (lastAssignedProperty == Property)
+        {
+            return;
+        }
+
+        lastAssignedProperty = Property;
+        compiledPropertyExpression = Property.Compile();
+
+        if (ChildContent == null)
+        {
+            if (!string.IsNullOrEmpty(Format) &&
+                typeof(IFormattable).IsAssignableFrom(Nullable.GetUnderlyingType(typeof(TProp)) ?? typeof(TProp)))
+            {
+                cellTextFunc = item => ((IFormattable?)compiledPropertyExpression(item))?.ToString(Format, null);
+            }
+            else
+            {
+                cellTextFunc = item => compiledPropertyExpression(item)?.ToString();
+            }
+        }
+
+        sortBy = DataSort.By(Property, key: Title.Camelize());
+    }
+}
diff --git a/Groceries/Components/Table.razor b/Groceries/Components/Table.razor
new file mode 100644
index 0000000..f24db59
--- /dev/null
+++ b/Groceries/Components/Table.razor
@@ -0,0 +1,207 @@
+@using Microsoft.EntityFrameworkCore
+@using Microsoft.EntityFrameworkCore.Query
+
+@typeparam TItem
+@attribute [CascadingTypeParameter(nameof(TItem))]
+
+@inject NavigationManager Navigation
+
+<table>
+    <CascadingValue IsFixed="true" Value="this">@ChildContent</CascadingValue>
+    <thead>
+        <tr>
+            @foreach (var column in columns)
+            {
+                <th scope="col"
+                    class="@GetHeaderClass(column)"
+                    style="@(column.Fill ? "width: 100%" : null)"
+                    aria-sort="@GetAriaSortValue(column)"
+                >
+                    @if (column.Sortable)
+                    {
+                        <a href="@GetUriForColumnSort(column)">@column.HeaderContent</a>
+                    }
+                    else
+                    {
+                        @column.HeaderContent
+                    }
+                </th>
+            }
+        </tr>
+    </thead>
+    <tbody>
+        @foreach (var item in currentPageItems)
+        {
+            <tr>
+                @foreach (var column in columns)
+                {
+                    <td class="@GetCellClass(column)">
+                        @column.CellContent(item)
+                    </td>
+                }
+            </tr>
+        }
+    </tbody>
+    @if (FooterContent != null)
+    {
+        <tfoot>@FooterContent</tfoot>
+    }
+</table>
+
+@code {
+    private readonly List<TableColumn<TItem>> columns = [];
+    private TItem[] currentPageItems = [];
+    private IQueryable<TItem>? lastAssignedItems;
+    private int? lastLoadedPaginationStateHash;
+
+    [Parameter, EditorRequired]
+    public required IQueryable<TItem> Items { get; set; }
+
+    [Parameter]
+    public PaginationState? Pagination { get; set; }
+
+    [Parameter]
+    public string? HeaderClass { get; set; }
+
+    [Parameter]
+    public string? CellClass { get; set; }
+
+    [Parameter]
+    public RenderFragment? ChildContent { get; set; }
+
+    [Parameter]
+    public RenderFragment? FooterContent { get; set; }
+
+    [SupplyParameterFromQuery(Name = "page")]
+    private int CurrentPage { get; set; }
+
+    [SupplyParameterFromQuery(Name = "sort")]
+    private string? SortKey { get; set; }
+
+    [SupplyParameterFromQuery(Name = "desc")]
+    private bool SortDescending { get; set; }
+
+    protected override async Task OnParametersSetAsync()
+    {
+        var dataSourceHasChanged = Items != lastAssignedItems;
+        if (dataSourceHasChanged)
+        {
+            lastAssignedItems = Items;
+        }
+
+        if (Pagination != null)
+        {
+            Pagination.CurrentPage = CurrentPage;
+        }
+
+        if (dataSourceHasChanged || Pagination?.GetHashCode() != lastLoadedPaginationStateHash)
+        {
+            await LoadDataAsync();
+        }
+    }
+
+    internal void AddColumn(TableColumn<TItem> column)
+    {
+        columns.Add(column);
+        StateHasChanged();
+    }
+
+    private async Task LoadDataAsync()
+    {
+        await LoadDataCoreAsync();
+        lastLoadedPaginationStateHash = Pagination?.GetHashCode();
+    }
+
+    private async Task LoadDataCoreAsync()
+    {
+        if (Pagination?.CurrentPage < 1)
+        {
+            Pagination.CurrentPage = 1;
+            NavigateToCurrentPage();
+            return;
+        }
+
+        var totalCount = Items.Provider is IAsyncQueryProvider
+            ? await Items.CountAsync()
+            : Items.Count();
+
+        var itemsQuery = Items;
+        if (SortKey != null &&
+            columns
+                .Select(column => column.SortBy)
+                .SingleOrDefault(sortBy => sortBy?.Key == SortKey) is DataSort<TItem> sortBy)
+        {
+            itemsQuery = sortBy.Apply(itemsQuery, SortDescending);
+        }
+
+        if (Pagination != null)
+        {
+            Pagination.TotalItemCount = totalCount;
+
+            if (Pagination.LastPage < Pagination.CurrentPage)
+            {
+                Pagination.CurrentPage = Pagination.LastPage;
+                NavigateToCurrentPage();
+                return;
+            }
+
+            itemsQuery = itemsQuery
+                .Skip(Pagination.Offset)
+                .Take(Pagination.PageSize);
+        }
+
+        currentPageItems = Items.Provider is IAsyncQueryProvider
+            ? await itemsQuery.ToArrayAsync()
+            : itemsQuery.ToArray();
+
+        if (Pagination != null)
+        {
+            Pagination.ItemCount = currentPageItems.Length;
+        }
+
+        StateHasChanged();
+    }
+
+    private void NavigateToCurrentPage()
+    {
+        Navigation.NavigateTo(Navigation.GetUriWithQueryParameter("page", Pagination!.CurrentPage));
+    }
+
+    private string? GetAriaSortValue(TableColumn<TItem> column)
+        => column.Sortable && SortKey == column.SortBy!.Key
+            ? (SortDescending ? "descending" : "ascending")
+            : null;
+
+    private string GetHeaderClass(TableColumn<TItem> column)
+    {
+        string?[] classes = [
+            "table__header",
+            column.Sortable ? "table__header--sortable" : null,
+            HeaderClass,
+        ];
+        return string.Join(' ', classes.Where(c => c != null));
+    }
+
+    private string GetCellClass(TableColumn<TItem> column)
+    {
+        string?[] classes = [
+            "table__cell",
+            column.Align switch
+            {
+                Align.Center => "table__cell--align-center",
+                Align.End => "table__cell--align-end",
+                _ => null,
+            },
+            CellClass,
+        ];
+        return string.Join(' ', classes.Where(c => c != null));
+    }
+
+    private string GetUriForColumnSort(TableColumn<TItem> column)
+    => Navigation.GetUriWithQueryParameters(new Dictionary<string, object?>
+        {
+            { "page", 1 },
+            { "sort", SortKey == column.SortBy!.Key && SortDescending ? null : column.SortBy.Key},
+            { "desc", SortKey != column.SortBy!.Key || SortDescending ? null : "true" },
+        });
+}
diff --git a/Groceries/Components/TableColumn.cs b/Groceries/Components/TableColumn.cs
new file mode 100644
index 0000000..dc31888
--- /dev/null
+++ b/Groceries/Components/TableColumn.cs
@@ -0,0 +1,36 @@
+namespace Groceries.Components;
+
+using Microsoft.AspNetCore.Components;
+
+public abstract class TableColumn<TItem> : ComponentBase
+{
+    [CascadingParameter]
+    private Table<TItem> Table { get; set; } = default!;
+
+    [Parameter]
+    public string? Title { get; set; }
+
+    [Parameter]
+    public Align? Align { get; set; }
+
+    [Parameter]
+    public bool Fill { get; set; }
+
+    [Parameter]
+    public RenderFragment HeaderContent { get; set; }
+
+    public abstract bool Sortable { get; set; }
+    public abstract DataSort<TItem>? SortBy { get; set; }
+
+    protected internal abstract RenderFragment<TItem> CellContent { get; }
+
+    public TableColumn()
+    {
+        HeaderContent = builder => builder.AddContent(0, Title);
+    }
+
+    protected override void OnInitialized()
+    {
+        Table.AddColumn(this);
+    }
+}
diff --git a/Groceries/Components/TablePaginator.razor b/Groceries/Components/TablePaginator.razor
index 3774bee..c7ffffd 100644
--- a/Groceries/Components/TablePaginator.razor
+++ b/Groceries/Components/TablePaginator.razor
@@ -1,34 +1,42 @@
 @inject NavigationManager Navigation
 
-<div class="table__paginator">
+<footer class="table__paginator">
     <span>
-        Showing @FirstItem to @LastItem of @Model.Total results
+        Showing @FirstItem to @LastItem of @State.TotalItemCount results
     </span>
     <nav class="button-group">
-        @if (Model.Page == 1)
+        @if (State.CurrentPage == 1)
         {
             <span class="link link--disabled">Previous</span>
         }
         else
         {
-            <a class="link" href="@Navigation.GetUriWithQueryParameter("page", Model.Page - 1)">Previous</a>
+            <a class="link" href="@GetUriForPage(State.CurrentPage - 1)">Previous</a>
         }
 
-        @if (Model.Page == Model.LastPage)
+        @if (State.CurrentPage == State.LastPage)
         {
             <span class="link link--disabled">Next</span>
         }
         else
         {
-            <a class="link" href="@Navigation.GetUriWithQueryParameter("page", Model.Page + 1)">Next</a>
+            <a class="link" href="@GetUriForPage(State.CurrentPage + 1)">Next</a>
         }
     </nav>
-</div>
+</footer>
 
 @code {
     [Parameter, EditorRequired]
-    public required IListPageModel Model { get; set; }
+    public required PaginationState State { get; set; }
 
-    private int FirstItem => Model.Count > 0 ? Model.Offset + 1 : 0;
-    private int LastItem => Model.Offset + Model.Count;
+    private int FirstItem => State.TotalItemCount > 0 ? State.Offset + 1 : 0;
+    private int LastItem => State.Offset + State.ItemCount;
+
+    protected override void OnParametersSet()
+    {
+        State.ItemCountChanged += (_, _) => StateHasChanged();
+    }
+
+    private string GetUriForPage(int page)
+        => Navigation.GetUriWithQueryParameter("page", page);
 }
diff --git a/Groceries/Components/TemplateTableColumn.cs b/Groceries/Components/TemplateTableColumn.cs
new file mode 100644
index 0000000..f00e2aa
--- /dev/null
+++ b/Groceries/Components/TemplateTableColumn.cs
@@ -0,0 +1,20 @@
+namespace Groceries.Components;
+
+using Microsoft.AspNetCore.Components;
+
+public class TemplateTableColumn<TItem> : TableColumn<TItem>
+{
+    [Parameter]
+    public RenderFragment<TItem> ChildContent { get; set; } = _ => _ => { };
+
+    [Parameter]
+    public override DataSort<TItem>? SortBy { get; set; }
+
+    public override bool Sortable
+    {
+        get => SortBy != null;
+        set => throw new NotSupportedException();
+    }
+
+    protected internal override RenderFragment<TItem> CellContent => ChildContent;
+}
diff --git a/Groceries/Items/ItemsPage.razor b/Groceries/Items/ItemsPage.razor
index c50ea0d..b5e8e98 100644
--- a/Groceries/Items/ItemsPage.razor
+++ b/Groceries/Items/ItemsPage.razor
@@ -2,65 +2,55 @@
 @using Microsoft.EntityFrameworkCore
 
 @layout Layout
-
 @inject AppDbContext DbContext
-@inject NavigationManager Navigation
-@inject IHttpContextAccessor HttpContextAccessor
 
 <PageTitle>Groceries &ndash; Items</PageTitle>
 
-<div class="row">
+<header class="row">
     <h1 class="row__fill">Items</h1>
-    <SearchForm data-turbo-frame="table" data-turbo-action="advance">
-        <input type="hidden" name="page" value="1" />
-    </SearchForm>
-</div>
+    <search title="Items">
+        <SearchForm data-turbo-frame="table" data-turbo-action="advance">
+            <input type="hidden" name="page" value="1" />
+        </SearchForm>
+    </search>
+</header>
 
 <turbo-frame id="table" target="top">
     <section class="table">
-        <table>
-            <thead>
-                <tr>
-                    <th scope="col" class="table__header table__header--shaded">Brand</th>
-                    <th scope="col" class="table__header table__header--shaded" style="width: 100%">Name</th>
-                    <th scope="col" class="table__header table__header--shaded">Last Purchased</th>
-                    <th scope="col" class="table__header table__header--shaded">Barcode</th>
-                    @*<th scope="col" class="table__header table__header--shaded"></th>*@
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var item in items)
-                {
-                    <tr>
-                        <td class="table__cell">@item.Brand</td>
-                        <td class="table__cell">@item.Name</td>
-                        <td class="table__cell">
-                            <time datetime="@item.LastPurchasedAt?.ToString("o")">@item.LastPurchasedAt?.ToLongDateString()</time>
-                        </td>
-                        <td class="table__cell table__cell--icon" style="width: fit-content">@(item.HasBarcode ? "✓" : "")</td>
-                        @*<td class="table__cell">
-                            <a class="link" href="/items/edit/@item.Id">Edit</a>
-                        </td>*@
-                    </tr>
-                }
-            </tbody>
-        </table>
-        <TablePaginator Model="items" />
+        <Table Items="items" Pagination="pagination" HeaderClass="table__header--shaded">
+            <PropertyTableColumn Property="i => i.Brand" Sortable="true" />
+            <PropertyTableColumn Property="i => i.Name" Fill="true" Sortable="true" />
+            <PropertyTableColumn Property="i => i.LastPurchasedAt" Title="Last Purchased" Sortable="true">
+                <time datetime="@context?.ToString("o")">@context?.ToLongDateString()</time>
+            </PropertyTableColumn>
+            <PropertyTableColumn Property="i => i.HasBarcode" Title="Barcode" Align="Align.Center" Sortable="true">
+                <span class="icon icon--sm">@(context ? "✓" : "")</span>
+            </PropertyTableColumn>
+            @* <TemplateTableColumn>
+                <a class="link" href="/items/edit/@context.Id">Edit</a>
+            </TemplateTableColumn> *@
+        </Table>
+        <TablePaginator State="pagination" />
     </section>
 </turbo-frame>
 
 @code {
-    [SupplyParameterFromQuery]
-    public int? Page { get; set; }
+    private record ItemModel
+    {
+        public Guid Id { get; init; }
+        public required string Brand { get; init; }
+        public required string Name { get; init; }
+        public bool HasBarcode { get; init; }
+        public DateTime? LastPurchasedAt { get; init; }
+    }
+
+    private IQueryable<ItemModel> items = null!;
+    private PaginationState pagination = new();
 
     [SupplyParameterFromQuery]
     public string? Search { get; set; }
 
-    private record ItemModel(Guid Id, string Brand, string Name, bool HasBarcode, DateTime? LastPurchasedAt);
-
-    private ListPageModel<ItemModel> items = ListPageModel.Empty<ItemModel>();
-
-    protected override async Task OnParametersSetAsync()
+    protected override void OnParametersSet()
     {
         var itemsQuery = DbContext.Items.AsQueryable();
         if (!string.IsNullOrEmpty(Search))
@@ -69,27 +59,23 @@
             itemsQuery = itemsQuery.Where(item => EF.Functions.ILike(item.Brand + ' ' + item.Name, searchPattern));
         }
 
-        items = await itemsQuery
-            .OrderBy(item => item.Brand)
-            .ThenBy(item => item.Name)
+        items = itemsQuery
             .GroupJoin(
                 DbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
                 item => item.Id,
-                lastPurchase => lastPurchase.ItemId,
-                (item, lastPurchase) => new { item, lastPurchase })
+                purchase => purchase.ItemId,
+                (item, purchases) => new { item, purchases })
             .SelectMany(
-                group => group.lastPurchase.DefaultIfEmpty(),
-                (group, lastPurchase) => new ItemModel(
-                    group.item.Id,
-                    group.item.Brand,
-                    group.item.Name,
-                    group.item.Barcodes.Count != 0,
-                    lastPurchase != null ? lastPurchase.CreatedAt : null))
-            .ToListPageModelAsync(Page.GetValueOrDefault(), cancellationToken: HttpContextAccessor.HttpContext!.RequestAborted);
-
-        if (items.Page != Page)
-        {
-            Navigation.NavigateTo(Navigation.GetUriWithQueryParameter("page", items.Page));
-        }
+                group => group.purchases.DefaultIfEmpty(),
+                (group, lastPurchase) => new ItemModel
+                {
+                    Id = group.item.Id,
+                    Brand = group.item.Brand,
+                    Name = group.item.Name,
+                    HasBarcode = group.item.Barcodes.Count != 0,
+                    LastPurchasedAt = lastPurchase != null ? lastPurchase.CreatedAt : null,
+                })
+            .OrderBy(item => item.Brand)
+            .ThenBy(item => item.Name);
     }
 }
diff --git a/Groceries/ListPageModel.cs b/Groceries/ListPageModel.cs
deleted file mode 100644
index efda7df..0000000
--- a/Groceries/ListPageModel.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-namespace Groceries;
-
-using Microsoft.EntityFrameworkCore;
-using System.Collections;
-
-public interface IListPageModel
-{
-    int Offset { get; }
-    int Page { get; }
-    int PageSize { get; }
-    int LastPage { get; }
-    int Total { get; }
-    int Count { get; }
-}
-
-public record ListPageModel<TItem> : IListPageModel, IReadOnlyCollection<TItem>
-{
-    public ListPageModel(IList<TItem> items)
-    {
-        Items = items;
-    }
-
-    public int Offset { get; init; }
-    public int Page { get; init; }
-    public int PageSize { get; init; }
-    public int LastPage { get; init; }
-    public int Total { get; init; }
-    public IList<TItem> Items { get; init; }
-
-    public int Count => Items.Count;
-
-    public IEnumerator<TItem> GetEnumerator()
-    {
-        return Items.GetEnumerator();
-    }
-
-    IEnumerator IEnumerable.GetEnumerator()
-    {
-        return Items.GetEnumerator();
-    }
-}
-
-public static class ListPageModel
-{
-    public static ListPageModel<TItem> Empty<TItem>()
-    {
-        return new ListPageModel<TItem>(Array.Empty<TItem>());
-    }
-}
-
-public static class ListPageModelExtensions
-{
-    public static async Task<ListPageModel<TItem>> ToListPageModelAsync<TItem>(
-        this IQueryable<TItem> query,
-        int page,
-        int pageSize = 10,
-        CancellationToken cancellationToken = default)
-    {
-        if (page < 1)
-        {
-            return new ListPageModel<TItem>(Array.Empty<TItem>()) { Page = 1 };
-        }
-
-        var total = await query.CountAsync(cancellationToken);
-        var lastPage = Math.Max(1, (int)Math.Ceiling((float)total / pageSize));
-
-        if (page > lastPage)
-        {
-            return new ListPageModel<TItem>(Array.Empty<TItem>()) { Page = lastPage };
-        }
-
-        var offset = (page - 1) * pageSize;
-
-        var items = await query
-            .Skip(offset)
-            .Take(pageSize)
-            .ToArrayAsync(cancellationToken);
-
-        return new ListPageModel<TItem>(items)
-        {
-            Offset = offset,
-            Page = page,
-            PageSize = pageSize,
-            LastPage = lastPage,
-            Total = total,
-        };
-    }
-}
diff --git a/Groceries/Stores/StoresPage.razor b/Groceries/Stores/StoresPage.razor
index 40d6a2e..9581cde 100644
--- a/Groceries/Stores/StoresPage.razor
+++ b/Groceries/Stores/StoresPage.razor
@@ -2,79 +2,68 @@
 @using Microsoft.EntityFrameworkCore
 
 @layout Layout
-
 @inject AppDbContext DbContext
-@inject NavigationManager Navigation
-@inject IHttpContextAccessor HttpContextAccessor
 
 <PageTitle>Groceries &ndash; Stores</PageTitle>
 
-<div class="row">
+<header class="row">
     <h1 class="row__fill">Stores</h1>
-    <SearchForm data-turbo-frame="table" data-turbo-action="advance">
-        <input type="hidden" name="page" value="1" />
-    </SearchForm>
+    <search title="Stores">
+        <SearchForm data-turbo-frame="table" data-turbo-action="advance">
+            <input type="hidden" name="page" value="1" />
+        </SearchForm>
+    </search>
     <a class="button button--primary" href="/stores/new" data-turbo-frame="modal">New store</a>
-</div>
+</header>
 
-<turbo-frame id="table" target="top">
+<turbo-frame id="table" target="_top">
     <section class="table">
-        <table>
-            <thead>
-                <tr>
-                    <th scope="col" class="table__header table__header--shaded">Retailer</th>
-                    <th scope="col" class="table__header table__header--shaded" style="width: 100%">Name</th>
-                    <th scope="col" class="table__header table__header--shaded">Transactions</th>
-                    <th scope="col" class="table__header table__header--shaded"></th>
-                </tr>
-            </thead>
-            <tbody>
-                @foreach (var store in stores)
-                {
-                    <tr>
-                        <td class="table__cell">@store.Retailer</td>
-                        <td class="table__cell">@store.Name</td>
-                        <td class="table__cell table__cell--numeric">@store.TransactionsCount</td>
-                        <td class="table__cell">
-                            <a class="link" href="/stores/edit/@store.Id" data-turbo-frame="modal">Edit</a>
-                        </td>
-                    </tr>
-                }
-            </tbody>
-        </table>
-        <TablePaginator Model="stores" />
+        <Table Items="stores" Pagination="pagination" HeaderClass="table__header--shaded">
+            <PropertyTableColumn Property="s => s.Retailer" Sortable="true" />
+            <PropertyTableColumn Property="s => s.Name" Fill="true" Sortable="true" />
+            <PropertyTableColumn Property="s => s.TransactionsCount" Title="Transactions" Sortable="true" />
+            <TemplateTableColumn Context="store">
+                <a class="link" href="/stores/edit/@store.Id" data-turbo-frame="modal">Edit</a>
+            </TemplateTableColumn>
+        </Table>
+        <TablePaginator State="pagination" />
     </section>
 </turbo-frame>
 
 @code {
-    [SupplyParameterFromQuery]
-    public int? Page { get; set; }
+    private record StoreModel
+    {
+        public Guid Id { get; init; }
+        public required string Retailer { get; init; }
+        public required string Name { get; init; }
+        public int TransactionsCount { get; init; }
+    }
+
+    private IQueryable<StoreModel> stores = null!;
+    private PaginationState pagination = new();
 
     [SupplyParameterFromQuery]
     public string? Search { get; set; }
 
-    private record StoreModel(Guid Id, string Retailer, string Name, int TransactionsCount);
-
-    private ListPageModel<StoreModel> stores = ListPageModel.Empty<StoreModel>();
-
-    protected override async Task OnParametersSetAsync()
+    protected override void OnParametersSet()
     {
         var storesQuery = DbContext.Stores.AsQueryable();
         if (!string.IsNullOrEmpty(Search))
         {
             var searchPattern = $"%{Search}%";
-            storesQuery = storesQuery.Where(store => EF.Functions.ILike(store.Retailer!.Name + ' ' + store.Name, searchPattern));
+            storesQuery = storesQuery
+                .Where(store => EF.Functions.ILike(store.Retailer!.Name + ' ' + store.Name, searchPattern));
         }
 
-        stores = await storesQuery
-            .OrderBy(store => store.Retailer!.Name)
-            .ThenBy(store => store.Name)
-            .Select(store => new StoreModel(store.Id, store.Retailer!.Name, store.Name, store.Transactions!.Count()))
-            .ToListPageModelAsync(Page.GetValueOrDefault(), cancellationToken: HttpContextAccessor.HttpContext!.RequestAborted);
-
-        if (stores.Page != Page)
-        {
-            Navigation.NavigateTo(Navigation.GetUriWithQueryParameter("page", stores.Page));
-        }
+        stores = storesQuery
+            .Select(store => new StoreModel
+            {
+                Id = store.Id,
+                Retailer = store.Retailer!.Name,
+                Name = store.Name,
+                TransactionsCount = store.Transactions!.Count(),
+            })
+            .OrderBy(store => store.Retailer)
+            .ThenBy(store => store.Name);
     }
 }
diff --git a/Groceries/Transactions/NewTransactionItemsPage.razor b/Groceries/Transactions/NewTransactionItemsPage.razor
index b03e1e2..cb612d3 100644
--- a/Groceries/Transactions/NewTransactionItemsPage.razor
+++ b/Groceries/Transactions/NewTransactionItemsPage.razor
@@ -2,7 +2,6 @@
 @using Microsoft.EntityFrameworkCore
 
 @layout Layout
-
 @inject AppDbContext DbContext
 
 <PageTitle>Groceries &ndash; New Transaction</PageTitle>
@@ -14,57 +13,45 @@
 </div>
 
 <form method="post">
-    <div class="card form-field">
+    <section class="card form-field">
         <div class="card__header row">
             <h2 class="row__fill">Items</h2>
-            <a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">New item</a>
+            <a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">
+                New item
+            </a>
         </div>
 
         <div class="card__content card__content--table">
-            <table>
-                <thead>
-                    <tr>
-                        <th scope="col" class="table__header" style="width: 100%">Name</th>
-                        <th scope="col" class="table__header">Price</th>
-                        <th scope="col" class="table__header"><abbr title="Quantity">Qty</abbr></th>
-                        <th scope="col" class="table__header">Amount</th>
-                        <th scope="col" class="table__header"></th>
-                    </tr>
-                </thead>
-                <tbody>
-                    @foreach (var item in Transaction.Items)
-                    {
-                        <tr>
-                            <td class="table__cell table__cell--compact">
-                                @itemNames.GetValueOrDefault(item.ItemId)
-                            </td>
-                            <td class="table__cell table__cell--compact table__cell--numeric">
-                                @item.Price.ToString("c")
-                            </td>
-                            <td class="table__cell table__cell--compact table__cell--numeric">
-                                @item.Quantity
-                            </td>
-                            <td class="table__cell table__cell--compact table__cell--numeric">
-                                @item.Amount.ToString("c")
-                            </td>
-                            <td class="table__cell table__cell--compact">
-                                <a class="link" href="/transactions/new/items/edit/@item.ItemId" data-turbo-frame="modal">Edit</a>
-                            </td>
-                        </tr>
-                    }
-                </tbody>
-                <tfoot>
+            <Table Items="Transaction.Items.AsQueryable()" CellClass="table__cell--compact">
+                <ChildContent>
+                    <TemplateTableColumn Title="Name" Fill="true" Context="item">
+                        @itemNames.GetValueOrDefault(item.ItemId)
+                    </TemplateTableColumn>
+                    <PropertyTableColumn Property="i => i.Price" Format="c" />
+                    <PropertyTableColumn Property="i => i.Quantity">
+                        <HeaderContent>
+                            <abbr title="Quantity">Qty</abbr>
+                        </HeaderContent>
+                    </PropertyTableColumn>
+                    <PropertyTableColumn Property="i => i.Amount" Format="c" />
+                    <TemplateTableColumn Context="item">
+                        <a class="link" href="/transactions/new/items/edit/@item.ItemId" data-turbo-frame="modal">
+                            Edit
+                        </a>
+                    </TemplateTableColumn>
+                </ChildContent>
+                <FooterContent>
                     <tr>
                         <td class="table__cell table__cell--compact table__cell--total" colspan="3">Subtotal</td>
-                        <td class="table__cell table__cell--compact table__cell--numeric table__cell--total">
+                        <td class="table__cell table__cell--compact table__cell--total table__cell--align-end">
                             @Transaction.Items.Sum(item => item.Amount).ToString("c")
                         </td>
                         <td class="table__cell table__cell--compact"></td>
                     </tr>
-                </tfoot>
-            </table>
+                </FooterContent>
+            </Table>
         </div>
-    </div>
+    </section>
 
     <div class="row">
         <button class="button button--primary" type="submit" disabled="@(Transaction.Items.Count == 0)">Next</button>
@@ -73,12 +60,12 @@
 </form>
 
 @code {
-    [Parameter]
-    public required Transaction Transaction { get; set; }
-
     private string store = string.Empty;
     private Dictionary<Guid, string> itemNames = new();
 
+    [Parameter]
+    public required Transaction Transaction { get; set; }
+
     protected override async Task OnParametersSetAsync()
     {
         store = await DbContext.Stores
diff --git a/Groceries/Transactions/NewTransactionPromotionsPage.razor b/Groceries/Transactions/NewTransactionPromotionsPage.razor
index 5f1b8bd..8a86769 100644
--- a/Groceries/Transactions/NewTransactionPromotionsPage.razor
+++ b/Groceries/Transactions/NewTransactionPromotionsPage.razor
@@ -2,7 +2,6 @@
 @using Microsoft.EntityFrameworkCore
 
 @layout Layout
-
 @inject AppDbContext DbContext
 
 <PageTitle>Groceries &ndash; New Transaction</PageTitle>
@@ -14,55 +13,42 @@
 </div>
 
 <form method="post">
-    <div class="card form-field">
-        <div class="card__header row">
+    <section class="card form-field">
+        <header class="card__header row">
             <h2 class="row__fill">Promotions</h2>
             <a class="button button--primary" href="/transactions/new/promotions/new" autofocus data-turbo-frame="modal">
                 New promotion
             </a>
-        </div>
+        </header>
 
         <div class="card__content card__content--table">
-            <table>
-                <thead>
-                    <tr>
-                        <th scope="col" class="table__header" style="width: 100%">Name</th>
-                        <th scope="col" class="table__header">Items</th>
-                        <th scope="col" class="table__header">Amount</th>
-                        <th scope="col" class="table__header"></th>
-                    </tr>
-                </thead>
-                <tbody>
-                    @foreach (var promotion in Transaction.Promotions)
-                    {
-                        <tr>
-                            <td class="table__cell table__cell--compact">
-                                @promotion.Name
-                            </td>
-                            <td class="table__cell table__cell--compact table__cell--numeric">
-                                @promotion.Items.Sum(item => Transaction.Items.Single(i => i.ItemId == item.Id).Quantity)
-                            </td>
-                            <td class="table__cell table__cell--compact table__cell--numeric">
-                                @((-promotion.Amount).ToString("c"))
-                            </td>
-                            <td class="table__cell table__cell--compact">
-                                <a class="link" href="/transactions/new/promotions/edit/@promotion.Id" data-turbo-frame="modal">Edit</a>
-                            </td>
-                        </tr>
-                    }
-                </tbody>
-                <tfoot>
+            <Table Items="Transaction.Promotions.AsQueryable()" CellClass="table__cell--compact">
+                <ChildContent>
+                    <PropertyTableColumn Property="p => p.Name" Fill="true" />
+                    <TemplateTableColumn Title="Items" Align="Align.End" Context="promotion">
+                        @promotion.Items.Sum(item => Transaction.Items.Single(i => i.ItemId == item.Id).Quantity)
+                    </TemplateTableColumn>
+                    <PropertyTableColumn Property="p => p.Amount" Context="amount">
+                        @((-amount).ToString("c"))
+                    </PropertyTableColumn>
+                    <TemplateTableColumn Context="promotion">
+                        <a class="link" href="/transactions/new/promotions/edit/@promotion.Id" data-turbo-frame="modal">
+                            Edit
+                        </a>
+                    </TemplateTableColumn>
+                </ChildContent>
+                <FooterContent>
                     <tr>
                         <td class="table__cell table__cell--compact table__cell--total" colspan="2">Total</td>
-                        <td class="table__cell table__cell--compact table__cell--numeric table__cell--total">
+                        <td class="table__cell table__cell--compact table__cell--total table__cell--align-end">
                             @Transaction.Total.ToString("c")
                         </td>
                         <td class="table__cell table__cell--compact"></td>
                     </tr>
-                </tfoot>
-            </table>
+                </FooterContent>
+            </Table>
         </div>
-    </div>
+    </section>
 
     <div class="row">
         <button class="button button--primary" type="submit" disabled="@(Transaction.Items.Count == 0)">Save</button>
@@ -71,11 +57,11 @@
 </form>
 
 @code {
+    private string store = string.Empty;
+
     [Parameter]
     public required Transaction Transaction { get; set; }
 
-    private string store = string.Empty;
-
     protected override async Task OnParametersSetAsync()
     {
         store = await DbContext.Stores
diff --git a/Groceries/Transactions/TransactionsPage.razor b/Groceries/Transactions/TransactionsPage.razor
index a32a4c8..0a13079 100644
--- a/Groceries/Transactions/TransactionsPage.razor
+++ b/Groceries/Transactions/TransactionsPage.razor
@@ -1,139 +1,58 @@
 @using Groceries.Data
-@layout Layout
 
+@layout Layout
 @inject AppDbContext DbContext
-@inject NavigationManager Navigation
-@inject IHttpContextAccessor HttpContextAccessor
 
 <PageTitle>Groceries &ndash; Transactions</PageTitle>
 
-<div class="row">
+<header class="row">
     <h1 class="row__fill">Transactions</h1>
     <a class="button button--primary form-field" href="/transactions/new">New transaction</a>
-</div>
+</header>
 
 <section class="table">
-    <table>
-        <thead>
-            <tr>
-                <th scope="col" class="table__header table__header--shaded table__header--sortable">
-                    <a href="@GetColumnHeaderUri("date")" data-dir="@GetColumnSortDirection("date")">
-                        Date
-                    </a>
-                </th>
-                <th scope="col" class="table__header table__header--shaded" style="width: 100%">Store</th>
-                <th scope="col" class="table__header table__header--shaded table__header--sortable">
-                    <a href="@GetColumnHeaderUri("items")" data-dir="@GetColumnSortDirection("items")">
-                        Items
-                    </a>
-                </th>
-                <th scope="col" class="table__header table__header--shaded table__header--sortable">
-                    <a href="@GetColumnHeaderUri("amount")" data-dir="@GetColumnSortDirection("amount")">
-                        Amount
-                    </a>
-                </th>
-                @* <th scope="col" class="table__header table__header--shaded"></th> *@
-            </tr>
-        </thead>
-        <tbody>
-            @foreach (var transaction in transactions)
-            {
-                <tr>
-                    <td class="table__cell">
-                        <time datetime="@transaction.CreatedAt.ToString("o")">@transaction.CreatedAt.ToLongDateString()</time>
-                    </td>
-                    <td class="table__cell">@transaction.Store</td>
-                    <td class="table__cell table__cell--numeric">@transaction.TotalItems</td>
-                    <td class="table__cell table__cell--numeric">@transaction.TotalAmount.ToString("c")</td>
-                    @*<td class="table__cell">View</td>*@
-                </tr>
-            }
-        </tbody>
-    </table>
-    <TablePaginator Model="transactions" />
+    <Table Items="transactions" Pagination="pagination" HeaderClass="table__header--shaded">
+        <PropertyTableColumn Property="t => t.CreatedAt" Title="Date" Sortable="true" Context="createdAt">
+            <time datetime="@createdAt.ToString("o")">@createdAt.ToLongDateString()</time>
+        </PropertyTableColumn>
+        <PropertyTableColumn Property="t => t.Store" Fill="true" Sortable="true" />
+        <PropertyTableColumn Property="t => t.TotalItems" Title="Items" Sortable="true" />
+        <PropertyTableColumn Property="t => t.TotalAmount" Title="Amount" Format="c" Sortable="true" />
+        @* <TemplateTableColumn>
+            <a class="link" href="/transactions/@context.Id">View</a>
+        </TemplateTableColumn> *@
+    </Table>
+    <TablePaginator State="pagination" />
 </section>
 
 @code {
-    [SupplyParameterFromQuery]
-    public int? Page { get; set; }
-
-    [SupplyParameterFromQuery(Name = "sort")]
-    public string? SortColumnName { get; set; }
-
-    [SupplyParameterFromQuery(Name = "dir")]
-    public string? SortDirection { get; set; }
-
-    private record TransactionModel(Guid Id, DateTime CreatedAt, string Store, decimal TotalAmount, int TotalItems);
-
-    private ListPageModel<TransactionModel> transactions = ListPageModel.Empty<TransactionModel>();
-
-    protected override async Task OnParametersSetAsync()
+    private record TransactionModel
     {
-        var transactionsQuery = DbContext.Transactions
+        public Guid Id { get; init; }
+        public DateTime CreatedAt { get; init; }
+        public required string Store { get; init; }
+        public decimal TotalAmount { get; init; }
+        public int TotalItems { get; init; }
+    }
+
+    private IQueryable<TransactionModel> transactions = null!;
+    private PaginationState pagination = new();
+
+    protected override void OnParametersSet()
+    {
+        transactions = DbContext.Transactions
             .Join(
                 DbContext.TransactionTotals,
                 transaction => transaction.Id,
                 transactionTotal => transactionTotal.TransactionId,
-                (transaction, transactionTotal) => new
+                (transaction, transactionTotal) => new TransactionModel
                 {
-                    transaction.Id,
-                    transaction.CreatedAt,
+                    Id = transaction.Id,
+                    CreatedAt = transaction.CreatedAt,
                     Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name),
                     TotalAmount = transactionTotal.Total,
                     TotalItems = transaction.Items.Sum(item => item.Quantity),
-                });
-
-        transactionsQuery = SortColumnName?.ToLowerInvariant() switch
-        {
-            "date" when SortDirection == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.CreatedAt),
-            "amount" when SortDirection == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.TotalAmount),
-            "items" when SortDirection == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.TotalItems),
-            "date" => transactionsQuery.OrderBy(transaction => transaction.CreatedAt),
-            "amount" => transactionsQuery.OrderBy(transaction => transaction.TotalAmount),
-            "items" => transactionsQuery.OrderBy(transaction => transaction.TotalItems),
-            _ => transactionsQuery.OrderByDescending(transaction => transaction.CreatedAt),
-        };
-
-        transactions = await transactionsQuery
-            .Select(transaction => new TransactionModel(
-                transaction.Id,
-                transaction.CreatedAt,
-                transaction.Store,
-                transaction.TotalAmount,
-                transaction.TotalItems))
-            .ToListPageModelAsync(Page.GetValueOrDefault(), cancellationToken: HttpContextAccessor.HttpContext!.RequestAborted);
-
-        if (transactions.Page != Page)
-        {
-            Navigation.NavigateTo(Navigation.GetUriWithQueryParameter("page", transactions.Page));
-        }
-    }
-
-    private string GetColumnHeaderUri(string name)
-    {
-        var nextSortDirecton = name == SortColumnName
-            ? SortDirection switch
-            {
-                null or "" => "asc",
-                "asc" => "desc",
-                _ => null,
-            }
-            : "asc";
-
-        return Navigation.GetUriWithQueryParameters(new Dictionary<string, object?>
-        {
-            { "sort", nextSortDirecton != null ? name : null },
-            { "dir", nextSortDirecton },
-            { "page", 1 },
-        });
-    }
-
-    private string? GetColumnSortDirection(string name)
-    {
-        return SortDirection switch
-        {
-            "asc" or "desc" when name == SortColumnName => SortDirection,
-            _ => null,
-        };
+                })
+            .OrderByDescending(transaction => transaction.CreatedAt);
     }
 }
diff --git a/Groceries/wwwroot/css/main.css b/Groceries/wwwroot/css/main.css
index 38410c3..8fa2398 100644
--- a/Groceries/wwwroot/css/main.css
+++ b/Groceries/wwwroot/css/main.css
@@ -91,11 +91,13 @@ h1, h2, h3, h4, h5, h6 {
 
 .icon {
     fill: currentColor;
+    font-size: 1.5rem;
     height: 1.5rem;
     width: 1.5rem;
 }
 
 .icon--sm {
+    font-size: 1.25rem;
     height: 1.25rem;
     width: 1.25rem;
 }
@@ -359,11 +361,11 @@ html:has(.modal[open]) {
     margin-left: 0.3rem;
 }
 
-.table__header--sortable a[data-dir=asc]::after {
+.table__header--sortable[aria-sort=ascending] a::after {
     content: "\25B2";
 }
 
-.table__header--sortable a[data-dir=desc]::after {
+.table__header--sortable[aria-sort=descending] a::after {
     content: "\25BC";
 }
 
@@ -379,19 +381,19 @@ html:has(.modal[open]) {
     white-space: nowrap;
 }
 
+.table__cell--align-center {
+    text-align: center;
+}
+
+.table__cell--align-end {
+    text-align: end;
+}
+
 .table__cell--compact {
     line-height: 1rem;
     padding-block: 0.75rem;
 }
 
-.table__cell--icon {
-    font-size: 1.25rem;
-}
-
-.table__cell--numeric {
-    text-align: end;
-}
-
 .table__cell--total {
     font-weight: 600;
     text-transform: uppercase;