Refactor tables to Razor components
This commit is contained in:
8
Groceries/Components/Align.cs
Normal file
8
Groceries/Components/Align.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Groceries.Components;
|
||||
|
||||
public enum Align
|
||||
{
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
49
Groceries/Components/DataSort.cs
Normal file
49
Groceries/Components/DataSort.cs
Normal 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;
|
||||
}
|
||||
}
|
27
Groceries/Components/PaginationState.cs
Normal file
27
Groceries/Components/PaginationState.cs
Normal 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);
|
||||
}
|
71
Groceries/Components/PropertyTableColumn.cs
Normal file
71
Groceries/Components/PropertyTableColumn.cs
Normal 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());
|
||||
}
|
||||
}
|
207
Groceries/Components/Table.razor
Normal file
207
Groceries/Components/Table.razor
Normal 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" },
|
||||
});
|
||||
}
|
36
Groceries/Components/TableColumn.cs
Normal file
36
Groceries/Components/TableColumn.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
20
Groceries/Components/TemplateTableColumn.cs
Normal file
20
Groceries/Components/TemplateTableColumn.cs
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user