Refactor tables to Razor components

This commit is contained in:
2023-12-10 20:36:48 +00:00
parent fd05501fad
commit 8223568ed6
15 changed files with 624 additions and 417 deletions

View File

@ -0,0 +1,8 @@
namespace Groceries.Components;
public enum Align
{
Start,
Center,
End,
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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" },
});
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}