diff --git a/debug/TestApplication/Models/User.cs b/debug/TestApplication/Models/User.cs index f20d288..70f839a 100644 --- a/debug/TestApplication/Models/User.cs +++ b/debug/TestApplication/Models/User.cs @@ -5,6 +5,8 @@ namespace TestApplication.Models; public class User { [Key] public Guid Id { get; } = Guid.CreateVersion7(); + + public int Index { get; set; } [EmailAddress, MaxLength(25)] public required string Email { get; set; } diff --git a/debug/TestApplication/Program.cs b/debug/TestApplication/Program.cs index 2d2ead7..bb3933d 100644 --- a/debug/TestApplication/Program.cs +++ b/debug/TestApplication/Program.cs @@ -23,6 +23,8 @@ builder.Services.AddHopFrame(config => { table.Property(u => u.Password) .Listable(false); + + table.SetPreferredProperty(u => u.Username); }); config.Table(table => { @@ -42,7 +44,7 @@ if (!app.Environment.IsDevelopment()) { await using (var scope = app.Services.CreateAsyncScope()) { var context = scope.ServiceProvider.GetRequiredService(); - foreach (var _ in Enumerable.Range(1, 100)) { + foreach (var i in Enumerable.Range(1, 100)) { var firstName = Faker.Name.First(); var lastName = Faker.Name.Last(); @@ -53,9 +55,22 @@ await using (var scope = app.Services.CreateAsyncScope()) { LastName = lastName, Description = Faker.Lorem.Paragraph(), 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(); + + 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(); } diff --git a/src/HopFrame.Core/Configuration/PropertyConfig.cs b/src/HopFrame.Core/Configuration/PropertyConfig.cs index 88d5dbd..cc602c1 100644 --- a/src/HopFrame.Core/Configuration/PropertyConfig.cs +++ b/src/HopFrame.Core/Configuration/PropertyConfig.cs @@ -36,6 +36,9 @@ public class PropertyConfig { /// [GENERATED] The place (from left to right) that the property will appear in the table and editor public int OrderIndex { get; set; } + + /// [GENERATED] The table that owns this property + public TableConfig Table { get; set; } internal PropertyConfig() {} } diff --git a/src/HopFrame.Core/Configuration/TableConfig.cs b/src/HopFrame.Core/Configuration/TableConfig.cs index c25fcc0..39ed910 100644 --- a/src/HopFrame.Core/Configuration/TableConfig.cs +++ b/src/HopFrame.Core/Configuration/TableConfig.cs @@ -28,5 +28,8 @@ public class TableConfig { /// [GENERATED] The place (from top to bottom) that the table will appear in on the sidebar 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() {} } \ No newline at end of file diff --git a/src/HopFrame.Core/Configurators/TableConfigurator.cs b/src/HopFrame.Core/Configurators/TableConfigurator.cs index 2edda3c..c250f3d 100644 --- a/src/HopFrame.Core/Configurators/TableConfigurator.cs +++ b/src/HopFrame.Core/Configurators/TableConfigurator.cs @@ -46,8 +46,8 @@ public class TableConfigurator(TableConfig config) where TModel : class return new PropertyConfigurator(prop); } - /// - public PropertyConfigurator Property(Expression> propertyExpression) { + /// + public PropertyConfigurator Property(Expression> propertyExpression) { var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name; var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName); @@ -56,4 +56,16 @@ public class TableConfigurator(TableConfig config) where TModel : class return new PropertyConfigurator(prop); } + + /// + public TableConfigurator SetPreferredProperty(Expression> 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; + } } \ No newline at end of file diff --git a/src/HopFrame.Core/EFCore/EfCoreRepository.cs b/src/HopFrame.Core/EFCore/EfCoreRepository.cs index 3e9f841..8fd9583 100644 --- a/src/HopFrame.Core/EFCore/EfCoreRepository.cs +++ b/src/HopFrame.Core/EFCore/EfCoreRepository.cs @@ -1,17 +1,29 @@ -using HopFrame.Core.Repositories; +using HopFrame.Core.Configuration; +using HopFrame.Core.Repositories; +using HopFrame.Core.Services; using Microsoft.EntityFrameworkCore; namespace HopFrame.Core.EFCore; -internal class EfCoreRepository(TContext context) : HopFrameRepository where TModel : class where TContext : DbContext { +internal class EfCoreRepository(TContext context, IConfigAccessor accessor) : HopFrameRepository where TModel : class where TContext : DbContext { public override async Task> LoadPageAsync(int page, int perPage, CancellationToken ct = default) { var set = context.Set(); - return await set + var query = set .AsNoTracking() .Skip(page * perPage) - .Take(perPage) - .ToArrayAsync(ct); //TODO: Implement FK loading + .Take(perPage); + + return await IncludeForeignKeys(query) + .ToArrayAsync(ct); + } + + private IQueryable IncludeForeignKeys(IQueryable 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 CountAsync(CancellationToken ct = default) { diff --git a/src/HopFrame.Core/Helpers/ConfigurationHelper.cs b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs index 13c2487..f9e4d78 100644 --- a/src/HopFrame.Core/Helpers/ConfigurationHelper.cs +++ b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs @@ -42,9 +42,13 @@ internal static class ConfigurationHelper { Type = property.PropertyType, DisplayName = property.Name, 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; } diff --git a/src/HopFrame.Core/Repositories/HopFrameRepository.cs b/src/HopFrame.Core/Repositories/HopFrameRepository.cs index 1cc61c8..c290031 100644 --- a/src/HopFrame.Core/Repositories/HopFrameRepository.cs +++ b/src/HopFrame.Core/Repositories/HopFrameRepository.cs @@ -24,12 +24,12 @@ public abstract class HopFrameRepository : IHopFrameRepository where TMo public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default); /// - public async Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { + public async Task> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { return await LoadPageAsync(page, perPage, ct); } /// - public async Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { + public async Task> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { return await SearchAsync(searchTerm, page, perPage, ct); } diff --git a/src/HopFrame.Core/Repositories/IHopFrameRepository.cs b/src/HopFrame.Core/Repositories/IHopFrameRepository.cs index 69db054..a395728 100644 --- a/src/HopFrame.Core/Repositories/IHopFrameRepository.cs +++ b/src/HopFrame.Core/Repositories/IHopFrameRepository.cs @@ -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; /// The generic repository that provides access to the model dataset @@ -11,7 +9,7 @@ public interface IHopFrameRepository { /// /// The index of the current page (starts at 0) /// The amount of entries that should be loaded - public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct); + public Task> LoadPageGenericAsync(int page, int perPage, CancellationToken ct); /// /// Returns the total amount of entries in the dataset @@ -24,7 +22,7 @@ public interface IHopFrameRepository { /// The search text provided by the user /// The index of the current page (starts at 0) /// The amount of entries that should be loaded - public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct); + public Task> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct); /// diff --git a/src/HopFrame.Core/Services/IEntityAccessor.cs b/src/HopFrame.Core/Services/IEntityAccessor.cs index d97aa99..1fba880 100644 --- a/src/HopFrame.Core/Services/IEntityAccessor.cs +++ b/src/HopFrame.Core/Services/IEntityAccessor.cs @@ -10,7 +10,14 @@ public interface IEntityAccessor { /// /// The model to pull the property from /// The property that shall be extracted - public Task GetValue(object model, PropertyConfig property); + public string? GetValue(object model, PropertyConfig property); + + /// + /// Formats the property to be displayed properly + /// + /// The value of the property + /// The property that shall be extracted + public string? FormatValue(object? value, PropertyConfig property); /// /// Properly formats and sets the new value of the property @@ -18,6 +25,14 @@ public interface IEntityAccessor { /// The model to save the property to /// The property that shall be modified /// The new value of the property - public Task SetValue(object model, PropertyConfig property, object value); + public void SetValue(object model, PropertyConfig property, object value); + + /// + /// Sorts the provided dataset by the specified property + /// + /// The dataset that needs to be sorted + /// The property that defines the sort order + /// Determines if the resulting order should be flipped + public IEnumerable SortDataByProperty(IEnumerable data, PropertyConfig property, bool descending = false); } \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs b/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs index a532cd6..406bbd7 100644 --- a/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs +++ b/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs @@ -1,25 +1,91 @@ -using HopFrame.Core.Configuration; +using System.Linq.Expressions; +using System.Reflection; +using HopFrame.Core.Configuration; namespace HopFrame.Core.Services.Implementation; -internal class EntityAccessor : IEntityAccessor { +internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor { - public async Task GetValue(object model, PropertyConfig property) { + public string? GetValue(object model, PropertyConfig property) { var prop = model.GetType().GetProperty(property.Identifier); - if (prop is 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) { - var prop = model.GetType().GetProperty(property.Identifier); + public string? FormatValue(object? value, PropertyConfig property) { + if (value is null) + return null; + + if ((property.PropertyType & PropertyType.List) != 0) { + return (value as IEnumerable)!.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) return; + + if (value.GetType() != property.Type) + value = Convert.ChangeType(value, property.Type); - prop.SetValue(model, Convert.ChangeType(value, property.Type)); + prop.SetValue(model, value); + } + + public IEnumerable SortDataByProperty(IEnumerable 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)result!; } } \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Components/Table.razor b/src/HopFrame.Web/Components/Components/Table.razor index de5d57a..26e5570 100644 --- a/src/HopFrame.Web/Components/Components/Table.razor +++ b/src/HopFrame.Web/Components/Components/Table.razor @@ -12,7 +12,8 @@ @Config.DisplayName - + + Reload @foreach (var prop in OrderedProperties) { - @if (prop.Sortable) { - @prop.DisplayName - } - else { + @prop.DisplayName - } + } diff --git a/src/HopFrame.Web/Components/Components/Table.razor.cs b/src/HopFrame.Web/Components/Components/Table.razor.cs index 3eedd99..f2f9d18 100644 --- a/src/HopFrame.Web/Components/Components/Table.razor.cs +++ b/src/HopFrame.Web/Components/Components/Table.razor.cs @@ -17,6 +17,12 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces private MudTable> Manager { get; set; } = null!; + private Dictionary> SortDirections { get; set; } = new(); + + private KeyValuePair? _currentSort; + + private string _searchText = string.Empty; + protected override void OnInitialized() { base.OnInitialized(); @@ -26,22 +32,19 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces .Where(p => p.Listable) .OrderBy(p => p.OrderIndex) .ToArray(); + + foreach (var property in OrderedProperties) { + SortDirections.Add(property.Identifier, null!); + } } - private async Task>> PrepareData(object[] entries) { + private List> PrepareData(object[] entries) { var list = new List>(); foreach (var entry in entries) { - var taskDict = new Dictionary>(); - foreach (var prop in OrderedProperties) { - taskDict.Add(prop.Identifier, accessor.GetValue(entry, prop)); - } - - await Task.WhenAll(taskDict.Values); - var dict = new Dictionary(); - foreach (var prop in taskDict) { - dict.Add(prop.Key, prop.Value.Result ?? string.Empty); + foreach (var prop in OrderedProperties) { + dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty); } list.Add(dict); @@ -51,8 +54,19 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces } private async Task>> Reload(TableState state, CancellationToken ct) { - var entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct); - var data = await PrepareData(entries.Cast().ToArray()); + IEnumerable entries; + + 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); return new TableData> { @@ -62,7 +76,27 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces } 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(); } - } \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Pages/TablePage.razor b/src/HopFrame.Web/Components/Pages/TablePage.razor index 1c5201f..c411824 100644 --- a/src/HopFrame.Web/Components/Pages/TablePage.razor +++ b/src/HopFrame.Web/Components/Pages/TablePage.razor @@ -1,6 +1,5 @@ @page "/admin/{TableRoute}" @using HopFrame.Web.Components.Components -@inherits CancellableComponent @rendermode InteractiveServer @layout HopFrameLayout diff --git a/src/HopFrame.Web/Components/Pages/TablePage.razor.cs b/src/HopFrame.Web/Components/Pages/TablePage.razor.cs index 1e2bb62..0fe78df 100644 --- a/src/HopFrame.Web/Components/Pages/TablePage.razor.cs +++ b/src/HopFrame.Web/Components/Pages/TablePage.razor.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Components; 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; [Parameter] diff --git a/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs b/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs index 75d6ea1..116a5f2 100644 --- a/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs +++ b/tests/HopFrame.Tests.Core/Configurators/HopFrameConfiguratorTests.cs @@ -1,5 +1,4 @@ -using System.Collections; -using HopFrame.Core.Configuration; +using HopFrame.Core.Configuration; using HopFrame.Core.Configurators; using HopFrame.Core.Repositories; using Microsoft.Extensions.DependencyInjection; @@ -8,13 +7,13 @@ namespace HopFrame.Tests.Core.Configurators; public class HopFrameConfiguratorTests { private class TestRepository : IHopFrameRepository { - public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { + public Task> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { throw new NotImplementedException(); } public Task CountAsync(CancellationToken ct) { throw new NotImplementedException(); } - public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { + public Task> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { throw new NotImplementedException(); } public Task CreateGenericAsync(object entry, CancellationToken ct) { diff --git a/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs b/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs index 42e2f3d..cdfda56 100644 --- a/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs +++ b/tests/HopFrame.Tests.Core/Services/Implementation/ConfigAccessorTests.cs @@ -1,5 +1,4 @@ -using System.Collections; -using HopFrame.Core.Configuration; +using HopFrame.Core.Configuration; using HopFrame.Core.Repositories; using HopFrame.Core.Services.Implementation; using Microsoft.Extensions.DependencyInjection; @@ -9,13 +8,13 @@ namespace HopFrame.Tests.Core.Services.Implementation; public class ConfigAccessorTests { private class TestRepository : IHopFrameRepository { - public Task LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { + public Task> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) { throw new NotImplementedException(); } public Task CountAsync(CancellationToken ct) { throw new NotImplementedException(); } - public Task SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { + public Task> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) { throw new NotImplementedException(); } public Task CreateGenericAsync(object entry, CancellationToken ct) {