Added sorting
All checks were successful
HopFrame CI / build (push) Successful in 55s
HopFrame CI / test (push) Successful in 1m6s

This commit is contained in:
2026-02-25 21:25:34 +01:00
parent ff2634ff41
commit e9e9fbf5e9
17 changed files with 221 additions and 58 deletions

View File

@@ -6,6 +6,8 @@ public class User {
[Key] [Key]
public Guid Id { get; } = Guid.CreateVersion7(); public Guid Id { get; } = Guid.CreateVersion7();
public int Index { get; set; }
[EmailAddress, MaxLength(25)] [EmailAddress, MaxLength(25)]
public required string Email { get; set; } public required string Email { get; set; }

View File

@@ -23,6 +23,8 @@ builder.Services.AddHopFrame(config => {
table.Property(u => u.Password) table.Property(u => u.Password)
.Listable(false); .Listable(false);
table.SetPreferredProperty(u => u.Username);
}); });
config.Table<Post>(table => { config.Table<Post>(table => {
@@ -42,7 +44,7 @@ if (!app.Environment.IsDevelopment()) {
await using (var scope = app.Services.CreateAsyncScope()) { await using (var scope = app.Services.CreateAsyncScope()) {
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
foreach (var _ in Enumerable.Range(1, 100)) { foreach (var i in Enumerable.Range(1, 100)) {
var firstName = Faker.Name.First(); var firstName = Faker.Name.First();
var lastName = Faker.Name.Last(); var lastName = Faker.Name.Last();
@@ -53,11 +55,24 @@ await using (var scope = app.Services.CreateAsyncScope()) {
LastName = lastName, LastName = lastName,
Description = Faker.Lorem.Paragraph(), Description = Faker.Lorem.Paragraph(),
Birth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()), Birth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()),
Password = Faker.RandomNumber.Next(100000L, 99999999999999999L).ToString() Password = Faker.RandomNumber.Next(100000L, 99999999999999999L).ToString(),
Index = i
}); });
} }
await context.SaveChangesAsync(); await context.SaveChangesAsync();
context.Posts.Add(new() {
Message = Faker.Lorem.Paragraph(),
Sender = context.Users.First()
});
context.Posts.Add(new() {
Message = Faker.Lorem.Paragraph(),
Sender = context.Users.Skip(1).First()
});
await context.SaveChangesAsync();
} }
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);

View File

@@ -37,6 +37,9 @@ public class PropertyConfig {
/// [GENERATED] The place (from left to right) that the property will appear in the table and editor /// [GENERATED] The place (from left to right) that the property will appear in the table and editor
public int OrderIndex { get; set; } public int OrderIndex { get; set; }
/// [GENERATED] The table that owns this property
public TableConfig Table { get; set; }
internal PropertyConfig() {} internal PropertyConfig() {}
} }

View File

@@ -28,5 +28,8 @@ public class TableConfig {
/// [GENERATED] The place (from top to bottom) that the table will appear in on the sidebar /// [GENERATED] The place (from top to bottom) that the table will appear in on the sidebar
public int OrderIndex { get; set; } public int OrderIndex { get; set; }
/// [GENERATED] The identifier of the property that should be displayed if the model is used as a relation
public string? PreferredProperty { get; set; }
internal TableConfig() {} internal TableConfig() {}
} }

View File

@@ -46,8 +46,8 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : class
return new PropertyConfigurator(prop); return new PropertyConfigurator(prop);
} }
/// <inheritdoc cref="Property"/> /// <inheritdoc cref="Property(string)"/>
public PropertyConfigurator Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) { public PropertyConfigurator Property(Expression<Func<TModel, object>> propertyExpression) {
var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name; var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name;
var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName); var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName);
@@ -56,4 +56,16 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : class
return new PropertyConfigurator(prop); return new PropertyConfigurator(prop);
} }
/// <inheritdoc cref="TableConfig.PreferredProperty"/>
public TableConfigurator<TModel> SetPreferredProperty(Expression<Func<TModel, object>> propertyExpression) {
var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name;
var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName);
if (prop is null)
throw new ArgumentException($"No attribute '{propertyName}' found in '{Config.Identifier}'!");
Config.PreferredProperty = prop.Identifier;
return this;
}
} }

View File

@@ -1,17 +1,29 @@
using HopFrame.Core.Repositories; using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.EFCore; namespace HopFrame.Core.EFCore;
internal class EfCoreRepository<TModel, TContext>(TContext context) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext { internal class EfCoreRepository<TModel, TContext>(TContext context, IConfigAccessor accessor) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
public override async Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) { public override async Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
var set = context.Set<TModel>(); var set = context.Set<TModel>();
return await set var query = set
.AsNoTracking() .AsNoTracking()
.Skip(page * perPage) .Skip(page * perPage)
.Take(perPage) .Take(perPage);
.ToArrayAsync(ct); //TODO: Implement FK loading
return await IncludeForeignKeys(query)
.ToArrayAsync(ct);
}
private IQueryable<TModel> IncludeForeignKeys(IQueryable<TModel> query) {
var table = accessor.GetTableByType(typeof(TModel))!;
return table.Properties
.Where(p => (p.PropertyType & PropertyType.Relation) != 0)
.Aggregate(query, (current, property) => current.Include(property.Identifier));
} }
public override async Task<int> CountAsync(CancellationToken ct = default) { public override async Task<int> CountAsync(CancellationToken ct = default) {

View File

@@ -42,9 +42,13 @@ internal static class ConfigurationHelper {
Type = property.PropertyType, Type = property.PropertyType,
DisplayName = property.Name, DisplayName = property.Name,
OrderIndex = table.Properties.Count, OrderIndex = table.Properties.Count,
PropertyType = InferPropertyType(property.PropertyType, property) PropertyType = InferPropertyType(property.PropertyType, property),
Table = table
}; };
if (property.CustomAttributes.Any(a => a.AttributeType == typeof(KeyAttribute)))
table.PreferredProperty = config.Identifier;
return config; return config;
} }

View File

@@ -24,12 +24,12 @@ public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TMo
public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default); public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default);
/// <inheritdoc/> /// <inheritdoc/>
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { public async Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
return await LoadPageAsync(page, perPage, ct); return await LoadPageAsync(page, perPage, ct);
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { public async Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
return await SearchAsync(searchTerm, page, perPage, ct); return await SearchAsync(searchTerm, page, perPage, ct);
} }

View File

@@ -1,6 +1,4 @@
using System.Collections; #pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
namespace HopFrame.Core.Repositories; namespace HopFrame.Core.Repositories;
/// The generic repository that provides access to the model dataset /// The generic repository that provides access to the model dataset
@@ -11,7 +9,7 @@ public interface IHopFrameRepository {
/// </summary> /// </summary>
/// <param name="page">The index of the current page (starts at 0)</param> /// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param> /// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct); public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct);
/// <summary> /// <summary>
/// Returns the total amount of entries in the dataset /// Returns the total amount of entries in the dataset
@@ -24,7 +22,7 @@ public interface IHopFrameRepository {
/// <param name="searchTerm">The search text provided by the user</param> /// <param name="searchTerm">The search text provided by the user</param>
/// <param name="page">The index of the current page (starts at 0)</param> /// <param name="page">The index of the current page (starts at 0)</param>
/// <param name="perPage">The amount of entries that should be loaded</param> /// <param name="perPage">The amount of entries that should be loaded</param>
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct); public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct);
/// <summary> /// <summary>

View File

@@ -10,7 +10,14 @@ public interface IEntityAccessor {
/// </summary> /// </summary>
/// <param name="model">The model to pull the property from</param> /// <param name="model">The model to pull the property from</param>
/// <param name="property">The property that shall be extracted</param> /// <param name="property">The property that shall be extracted</param>
public Task<string?> GetValue(object model, PropertyConfig property); public string? GetValue(object model, PropertyConfig property);
/// <summary>
/// Formats the property to be displayed properly
/// </summary>
/// <param name="value">The value of the property</param>
/// <param name="property">The property that shall be extracted</param>
public string? FormatValue(object? value, PropertyConfig property);
/// <summary> /// <summary>
/// Properly formats and sets the new value of the property /// Properly formats and sets the new value of the property
@@ -18,6 +25,14 @@ public interface IEntityAccessor {
/// <param name="model">The model to save the property to</param> /// <param name="model">The model to save the property to</param>
/// <param name="property">The property that shall be modified</param> /// <param name="property">The property that shall be modified</param>
/// <param name="value">The new value of the property</param> /// <param name="value">The new value of the property</param>
public Task SetValue(object model, PropertyConfig property, object value); public void SetValue(object model, PropertyConfig property, object value);
/// <summary>
/// Sorts the provided dataset by the specified property
/// </summary>
/// <param name="data">The dataset that needs to be sorted</param>
/// <param name="property">The property that defines the sort order</param>
/// <param name="descending">Determines if the resulting order should be flipped</param>
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false);
} }

View File

@@ -1,25 +1,91 @@
using HopFrame.Core.Configuration; using System.Linq.Expressions;
using System.Reflection;
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Services.Implementation; namespace HopFrame.Core.Services.Implementation;
internal class EntityAccessor : IEntityAccessor { internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
public async Task<string?> GetValue(object model, PropertyConfig property) { public string? GetValue(object model, PropertyConfig property) {
var prop = model.GetType().GetProperty(property.Identifier); var prop = model.GetType().GetProperty(property.Identifier);
if (prop is null) if (prop is null)
return null; return null;
return prop.GetValue(model)?.ToString(); var value = prop.GetValue(model);
return FormatValue(value, property);
} }
public async Task SetValue(object model, PropertyConfig property, object value) { public string? FormatValue(object? value, PropertyConfig property) {
var prop = model.GetType().GetProperty(property.Identifier); if (value is null)
return null;
if ((property.PropertyType & PropertyType.List) != 0) {
return (value as IEnumerable<object>)!.Count().ToString();
}
if ((property.PropertyType & PropertyType.Relation) != 0) {
var table = accessor.GetTableByType(property.Type);
if (table?.PreferredProperty != null) {
var tableProp = table.Properties.First(p => p.Identifier == table.PreferredProperty);
return GetValue(value, tableProp);
}
}
return value.ToString();
}
public void SetValue(object model, PropertyConfig property, object value) {
var prop = model.GetType().GetProperty(property.Identifier);
if (prop is null) if (prop is null)
return; return;
prop.SetValue(model, Convert.ChangeType(value, property.Type)); if (value.GetType() != property.Type)
value = Convert.ChangeType(value, property.Type);
prop.SetValue(model, value);
}
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false) {
var prop = property.Table.TableType.GetProperty(property.Identifier);
if (prop is null)
return data;
var parameter = Expression.Parameter(property.Table.TableType);
Expression expression = Expression.Property(parameter, prop);
var targetType = prop.PropertyType;
if ((property.PropertyType & PropertyType.Relation) != 0) {
var relationTable = accessor.GetTableByType(property.Type);
PropertyInfo? relationPropInfo = null;
if (relationTable?.PreferredProperty != null) {
var relationProp = relationTable.Properties.First(p => p.Identifier == relationTable.PreferredProperty);
if ((relationProp.PropertyType & PropertyType.List) == 0)
relationPropInfo = relationProp.Type.GetProperty(relationProp.Identifier);
}
if (relationPropInfo == null) {
var formatMethod = GetType().GetMethod(nameof(FormatValue))!;
targetType = typeof(string);
expression = Expression.Call(Expression.Constant(this), formatMethod, expression, Expression.Constant(property));
}
else {
targetType = relationPropInfo.PropertyType;
expression = Expression.Property(expression, relationPropInfo);
}
}
var lambda = Expression.Lambda(expression, parameter);
var methodName = descending ? nameof(Enumerable.OrderByDescending) : nameof(Enumerable.OrderBy);
var method = typeof(Enumerable)
.GetMethods()
.Single(m => m.Name == methodName && m.GetParameters().Length == 2)
.MakeGenericMethod(property.Table.TableType, targetType);
var result = method.Invoke(null, [data, lambda.Compile()]);
return (IEnumerable<object>)result!;
} }
} }

View File

@@ -12,7 +12,8 @@
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">@Config.DisplayName</MudText> <MudText Typo="Typo.h6">@Config.DisplayName</MudText>
<MudSpacer /> <MudSpacer />
<MudStack Row="true" Spacing="2" Style="min-width: 500px"> <MudStack Row="true" Spacing="2" Style="min-width: 600px">
<MudButton OnClick="@(Manager.ReloadServerData)">Reload</MudButton>
<MudTextField <MudTextField
T="string" T="string"
Placeholder="Search" Placeholder="Search"
@@ -30,12 +31,13 @@
<HeaderContent> <HeaderContent>
@foreach (var prop in OrderedProperties) { @foreach (var prop in OrderedProperties) {
<MudTh> <MudTh>
@if (prop.Sortable) { <MudTableSortLabel
<MudTableSortLabel SortLabel="@prop.Identifier" T="string">@prop.DisplayName</MudTableSortLabel> T="object"
} @ref="SortDirections[prop.Identifier]"
else { SortDirectionChanged="@(dir => OnSort(prop, dir))"
Enabled="@prop.Sortable">
@prop.DisplayName @prop.DisplayName
} </MudTableSortLabel>
</MudTh> </MudTh>
} }

View File

@@ -17,6 +17,12 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
private MudTable<Dictionary<string, string>> Manager { get; set; } = null!; private MudTable<Dictionary<string, string>> Manager { get; set; } = null!;
private Dictionary<string, MudTableSortLabel<object>> SortDirections { get; set; } = new();
private KeyValuePair<string, SortDirection>? _currentSort;
private string _searchText = string.Empty;
protected override void OnInitialized() { protected override void OnInitialized() {
base.OnInitialized(); base.OnInitialized();
@@ -26,22 +32,19 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
.Where(p => p.Listable) .Where(p => p.Listable)
.OrderBy(p => p.OrderIndex) .OrderBy(p => p.OrderIndex)
.ToArray(); .ToArray();
foreach (var property in OrderedProperties) {
SortDirections.Add(property.Identifier, null!);
}
} }
private async Task<List<Dictionary<string, string>>> PrepareData(object[] entries) { private List<Dictionary<string, string>> PrepareData(object[] entries) {
var list = new List<Dictionary<string, string>>(); var list = new List<Dictionary<string, string>>();
foreach (var entry in entries) { foreach (var entry in entries) {
var taskDict = new Dictionary<string, Task<string?>>();
foreach (var prop in OrderedProperties) {
taskDict.Add(prop.Identifier, accessor.GetValue(entry, prop));
}
await Task.WhenAll(taskDict.Values);
var dict = new Dictionary<string, string>(); var dict = new Dictionary<string, string>();
foreach (var prop in taskDict) { foreach (var prop in OrderedProperties) {
dict.Add(prop.Key, prop.Value.Result ?? string.Empty); dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty);
} }
list.Add(dict); list.Add(dict);
@@ -51,8 +54,19 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
} }
private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) { private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) {
var entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct); IEnumerable<object> entries;
var data = await PrepareData(entries.Cast<object>().ToArray());
if (string.IsNullOrWhiteSpace(_searchText))
entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct);
else
entries = await Repository.SearchGenericAsync(_searchText, state.Page, state.PageSize, ct);
if (_currentSort.HasValue) {
var sortProp = Config.Properties.First(p => p.Identifier == _currentSort.Value.Key);
entries = accessor.SortDataByProperty(entries, sortProp, _currentSort.Value.Value == SortDirection.Descending);
}
var data = PrepareData(entries.ToArray());
var total = await Repository.CountAsync(ct); var total = await Repository.CountAsync(ct);
return new TableData<Dictionary<string, string>> { return new TableData<Dictionary<string, string>> {
@@ -62,7 +76,27 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
} }
private async Task OnSearch(string searchText) { private async Task OnSearch(string searchText) {
Console.WriteLine(searchText); _searchText = searchText;
await Manager.ReloadServerData();
} }
private async Task OnSort(PropertyConfig property, SortDirection direction) {
if (direction != SortDirection.None) {
foreach (var reference in SortDirections
.Where(d => d.Key != property.Identifier)) {
#pragma warning disable BL0005
reference.Value.SortDirection = SortDirection.None;
#pragma warning restore BL0005
}
}
if (direction == SortDirection.None) {
_currentSort = null;
}
else {
_currentSort = new(property.Identifier, direction);
}
await Manager.ReloadServerData();
}
} }

View File

@@ -1,6 +1,5 @@
@page "/admin/{TableRoute}" @page "/admin/{TableRoute}"
@using HopFrame.Web.Components.Components @using HopFrame.Web.Components.Components
@inherits CancellableComponent
@rendermode InteractiveServer @rendermode InteractiveServer
@layout HopFrameLayout @layout HopFrameLayout

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Components;
namespace HopFrame.Web.Components.Pages; namespace HopFrame.Web.Components.Pages;
public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : CancellableComponent { public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : ComponentBase {
private const int PerPage = 25; private const int PerPage = 25;
[Parameter] [Parameter]

View File

@@ -1,5 +1,4 @@
using System.Collections; using HopFrame.Core.Configuration;
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators; using HopFrame.Core.Configurators;
using HopFrame.Core.Repositories; using HopFrame.Core.Repositories;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -8,13 +7,13 @@ namespace HopFrame.Tests.Core.Configurators;
public class HopFrameConfiguratorTests { public class HopFrameConfiguratorTests {
private class TestRepository : IHopFrameRepository { private class TestRepository : IHopFrameRepository {
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<int> CountAsync(CancellationToken ct) { public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task CreateGenericAsync(object entry, CancellationToken ct) { public Task CreateGenericAsync(object entry, CancellationToken ct) {

View File

@@ -1,5 +1,4 @@
using System.Collections; using HopFrame.Core.Configuration;
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories; using HopFrame.Core.Repositories;
using HopFrame.Core.Services.Implementation; using HopFrame.Core.Services.Implementation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -9,13 +8,13 @@ namespace HopFrame.Tests.Core.Services.Implementation;
public class ConfigAccessorTests { public class ConfigAccessorTests {
private class TestRepository : IHopFrameRepository { private class TestRepository : IHopFrameRepository {
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<int> CountAsync(CancellationToken ct) { public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task CreateGenericAsync(object entry, CancellationToken ct) { public Task CreateGenericAsync(object entry, CancellationToken ct) {