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 – 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 – 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 – 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 – 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 – 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;