From 4f68fc578fd8fe635cecb44b96607eb81bf32498 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Fri, 17 Jan 2025 16:58:36 +0100 Subject: [PATCH] Added n -> m relation support --- .idea/.idea.HopFrame/.idea/workspace.xml | 30 ++++--- src/HopFrame.Core/Config/PropertyConfig.cs | 10 ++- src/HopFrame.Core/Services/ITableManager.cs | 2 +- .../Implementations/ContextExplorer.cs | 18 ++-- .../Services/Implementations/TableManager.cs | 29 ++++--- .../Components/Dialogs/HopFrameEditor.razor | 85 ++++++++++++++----- .../Dialogs/HopFrameRelationPicker.razor | 8 +- .../Components/Layout/HopFrameSideMenu.razor | 2 +- .../Components/Pages/HopFrameTablePage.razor | 56 +++++++----- .../Models/RelationPickerDialogData.cs | 5 +- src/HopFrame.Web/wwwroot/hopframe.css | 12 +++ .../Components/Pages/Home.razor | 1 + testing/HopFrame.Testing/DatabaseContext.cs | 4 +- testing/HopFrame.Testing/Models/Post.cs | 2 +- testing/HopFrame.Testing/Models/User.cs | 2 + testing/HopFrame.Testing/Program.cs | 8 +- 16 files changed, 195 insertions(+), 79 deletions(-) diff --git a/.idea/.idea.HopFrame/.idea/workspace.xml b/.idea/.idea.HopFrame/.idea/workspace.xml index 758f9e9..8e203ff 100644 --- a/.idea/.idea.HopFrame/.idea/workspace.xml +++ b/.idea/.idea.HopFrame/.idea/workspace.xml @@ -10,24 +10,22 @@ - - - - - + - - + + + + + - @@ -275,6 +282,7 @@ - \ No newline at end of file diff --git a/src/HopFrame.Core/Config/PropertyConfig.cs b/src/HopFrame.Core/Config/PropertyConfig.cs index cf0ff69..754d42d 100644 --- a/src/HopFrame.Core/Config/PropertyConfig.cs +++ b/src/HopFrame.Core/Config/PropertyConfig.cs @@ -1,4 +1,5 @@ -using System.Linq.Expressions; +using System.Collections; +using System.Linq.Expressions; using System.Reflection; namespace HopFrame.Core.Config; @@ -12,6 +13,7 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert public bool Searchable { get; set; } = true; public PropertyInfo? DisplayedProperty { get; set; } public Func? Formatter { get; set; } + public Func? EnumerableFormatter { get; set; } public Func? Parser { get; set; } public Func>>? Validator { get; set; } public bool Editable { get; set; } = true; @@ -19,6 +21,7 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert public bool DisplayValue { get; set; } = true; public bool IsRelation { get; set; } public bool IsRequired { get; set; } + public bool IsEnumerable { get; set; } public bool IsListingProperty { get; set; } public int Order { get; set; } = nthProperty; } @@ -57,6 +60,11 @@ public class PropertyConfig(PropertyConfig config) { return this; } + public PropertyConfig FormatEach(Func formatter) { + InnerConfig.EnumerableFormatter = obj => formatter.Invoke((TInnerProp)obj); + return this; + } + public PropertyConfig ValueParser(Func parser) { InnerConfig.Parser = str => parser.Invoke(str)!; return this; diff --git a/src/HopFrame.Core/Services/ITableManager.cs b/src/HopFrame.Core/Services/ITableManager.cs index 76baa43..9d035fa 100644 --- a/src/HopFrame.Core/Services/ITableManager.cs +++ b/src/HopFrame.Core/Services/ITableManager.cs @@ -12,5 +12,5 @@ public interface ITableManager { public Task AddItem(object item); public Task RevertChanges(object item); - public string DisplayProperty(object? item, PropertyConfig prop, TableConfig? tableConfig); + public string DisplayProperty(object? item, PropertyConfig prop, object? value = null); } \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs index 678819a..7cf2b94 100644 --- a/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs +++ b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs @@ -1,4 +1,5 @@ -using HopFrame.Core.Config; +using System.Text.Json; +using HopFrame.Core.Config; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -58,12 +59,15 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr var dbContext = (provider.GetService(table.ContextConfig.ContextType) as DbContext)!; var entity = dbContext.Model.FindEntityType(table.TableType)!; - foreach (var key in entity.GetForeignKeys()) { - var propConfig = table.Properties - .Where(prop => !prop.IsListingProperty) - .SingleOrDefault(prop => prop.Info == key.DependentToPrincipal?.PropertyInfo); - if (propConfig is null) continue; - propConfig.IsRelation = true; + foreach (var propertyConfig in table.Properties) { + if (propertyConfig.IsListingProperty) continue; + var prop = entity.FindProperty(propertyConfig.Info.Name); + if (prop is not null) continue; + var nav = entity.FindNavigation(propertyConfig.Info.Name); + if (nav is null) continue; + propertyConfig.IsRelation = true; + propertyConfig.IsRequired = nav.ForeignKey.IsRequired; + propertyConfig.IsEnumerable = nav.IsCollection; } foreach (var property in entity.GetProperties()) { diff --git a/src/HopFrame.Core/Services/Implementations/TableManager.cs b/src/HopFrame.Core/Services/Implementations/TableManager.cs index c725510..e284001 100644 --- a/src/HopFrame.Core/Services/Implementations/TableManager.cs +++ b/src/HopFrame.Core/Services/Implementations/TableManager.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Reflection; using HopFrame.Core.Config; @@ -67,13 +68,13 @@ internal sealed class TableManager(DbContext context, TableConfig config return false; } - public string DisplayProperty(object? item, PropertyConfig prop, TableConfig? tableConfig) { + public string DisplayProperty(object? item, PropertyConfig prop, object? value = null) { if (item is null) return string.Empty; if (prop.IsListingProperty) return prop.Formatter!.Invoke(item); - var propValue = prop.Info.GetValue(item); + var propValue = value ?? prop.Info.GetValue(item); if (propValue is null) return string.Empty; @@ -81,6 +82,18 @@ internal sealed class TableManager(DbContext context, TableConfig config return prop.Formatter.Invoke(propValue); } + if (prop.IsEnumerable) { + if (value is not null) { + if (prop.EnumerableFormatter is not null) { + return prop.EnumerableFormatter.Invoke(value); + } + + return value.ToString() ?? string.Empty; + } + + return (propValue as IEnumerable)!.OfType().Count().ToString(); + } + if (prop.DisplayedProperty is null) { var key = prop.Info.PropertyType .GetProperties() @@ -94,19 +107,13 @@ internal sealed class TableManager(DbContext context, TableConfig config .SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsListingProperty); if (innerProp is null) return propValue.ToString() ?? string.Empty; - return DisplayProperty(propValue, innerProp, innerConfig); + return DisplayProperty(propValue, innerProp); } private IQueryable IncludeForgeinKeys(IQueryable query) { var pendingQuery = query; - foreach (var property in config.Properties) { - var attr = property.Info - .GetCustomAttributes(true) - .FirstOrDefault(att => att is ForeignKeyAttribute) as ForeignKeyAttribute; - - if (attr is null) continue; - + foreach (var property in config.Properties.Where(prop => prop.IsRelation)) { pendingQuery = pendingQuery.Include(property.Info.Name); } diff --git a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor index a67b155..1fc16ac 100644 --- a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor +++ b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor @@ -1,11 +1,11 @@ @implements IDialogContentComponent @rendermode InteractiveServer +@using System.Collections @using HopFrame.Core.Config @using HopFrame.Core.Services @using HopFrame.Web.Models @using HopFrame.Web.Helpers -@using Microsoft.EntityFrameworkCore.Internal @foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) { @@ -15,18 +15,31 @@ @if (property.IsRelation) {
- + @if (property.IsEnumerable) { + @property.Name +
+ + @foreach (var item in GetPropertyValue(property) ?? Enumerable.Empty()) { + @(GetPropertyValue(property, item)) + } + + + } + else { + + }
- - - + @if (!property.IsRequired) { + + + + } @@ -168,19 +181,24 @@ } } - private TValue? GetPropertyValue(PropertyConfig config) { + private TValue? GetPropertyValue(PropertyConfig config, object? listItem = null) { if (!config.DisplayValue) return default; if (Content.CurrentObject is null) return default; + + if (listItem is not null) { + return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, listItem); + } + var value = config.Info.GetValue(Content.CurrentObject); if (value is null) return default; - if (config.Info.PropertyType == typeof(TValue)) + if (typeof(TValue).IsAssignableFrom(config.Info.PropertyType)) return (TValue)value; if (typeof(TValue) == typeof(string)) - return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, Content.Config); + return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config); return (TValue)Convert.ChangeType(value, typeof(TValue)); } @@ -190,6 +208,7 @@ return; object? result = null; + var needsOverride = true; if (value is not null && config.Parser is null) { switch (senderType) { @@ -231,7 +250,16 @@ break; case InputType.Relation: - result = value; + if (!config.IsEnumerable) + result = ((IEnumerable)value).OfType().FirstOrDefault(); + else { + needsOverride = false; + var asList = (IList)config.Info.GetValue(Content.CurrentObject)!; + asList.Clear(); + foreach (var element in (IEnumerable)value) { + asList.Add(element); + } + } break; default: @@ -243,23 +271,40 @@ result = config.Parser(result.ToString()!); } - config.Info.SetValue(Content.CurrentObject, result); + if (needsOverride) + config.Info.SetValue(Content.CurrentObject, result); } private async Task OpenRelationalPicker(PropertyConfig config) { if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy)) return; + + var relationType = config.Info.PropertyType; + if (config.IsEnumerable) { + relationType = config.Info.PropertyType.GetGenericArguments().First(); + } - var relationTable = Explorer.GetTable(config.Info.PropertyType); + var relationTable = Explorer.GetTable(relationType); if (relationTable is null) return; - var currentValue = config.Info.GetValue(Content.CurrentObject); - var dialog = await Dialogs.ShowDialogAsync(new RelationPickerDialogData(relationTable, currentValue), new DialogParameters()); + var currentValues = new List(); + if (config.IsEnumerable) { + foreach (var o in GetPropertyValue(config) ?? Enumerable.Empty()) { + currentValues.Add(o); + } + } + else { + var raw = config.Info.GetValue(Content.CurrentObject); + if (raw is not null) + currentValues.Add(raw); + } + + var dialog = await Dialogs.ShowDialogAsync(new RelationPickerDialogData(relationTable, currentValues, config.IsEnumerable), new DialogParameters()); var result = await dialog.Result; if (result.Cancelled) return; var data = (RelationPickerDialogData)result.Data!; - await SetPropertyValue(config, data.Object, InputType.Relation); + await SetPropertyValue(config, data.SelectedObjects, InputType.Relation); } private async Task ValidateInputs() { diff --git a/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor index 3ca46a5..d201ccd 100644 --- a/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor +++ b/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor @@ -5,7 +5,13 @@ @using HopFrame.Web.Components.Pages - + @code { diff --git a/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor b/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor index c78f3c7..d839091 100644 --- a/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor +++ b/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor @@ -1,7 +1,7 @@ @using HopFrame.Core.Config @using HopFrame.Core.Services - + - @if (!DisplaySelection && _hasCreatePolicy) { + @if (_hasCreatePolicy && DisplayActions) { Add Entry }
- @if (DisplaySelection) { -
- @foreach (var model in _currentlyDisplayedModels) { -
- - - - -
- } -
- } -
+ @if (DisplaySelection) { + + } + @foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) { } @@ -65,16 +63,16 @@ @{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); } - + @if (_hasUpdatePolicy) { - + } @if (_hasDeletePolicy) { - + } @@ -143,13 +141,16 @@ [Parameter] public RelationPickerDialogData? DialogData { get; set; } + [Parameter] + public DataGridSelectMode SelectionMode { get; set; } = DataGridSelectMode.Single; + [Parameter] public int PerPage { get; set; } = 20; private TableConfig? _config; private ITableManager? _manager; - private IEnumerable _currentlyDisplayedModels = []; + private object[] _currentlyDisplayedModels = []; private int _currentPage; private int _totalPages; private string? _searchTerm; @@ -159,6 +160,8 @@ private bool _hasDeletePolicy; private bool _hasCreatePolicy; + private SelectColumn? _selectColumn; + protected override void OnInitialized() { _config ??= Explorer.GetTable(TableDisplayName); @@ -263,4 +266,19 @@ await Reload(); } + + private void SelectItem(object item, bool selected) { + if (!selected) + DialogData?.SelectedObjects.Remove(item); + else DialogData?.SelectedObjects.Add(item); + } + + private void SelectAll(bool? selected) { + selected = _currentlyDisplayedModels.Any(obj => DialogData?.SelectedObjects.Contains(obj) != true); + foreach (var displayedModel in _currentlyDisplayedModels) { + SelectItem(displayedModel, selected == true); + } + + _selectColumn!.SelectAll = selected; + } } \ No newline at end of file diff --git a/src/HopFrame.Web/Models/RelationPickerDialogData.cs b/src/HopFrame.Web/Models/RelationPickerDialogData.cs index 3d48bf1..a58a244 100644 --- a/src/HopFrame.Web/Models/RelationPickerDialogData.cs +++ b/src/HopFrame.Web/Models/RelationPickerDialogData.cs @@ -2,7 +2,8 @@ namespace HopFrame.Web.Models; -public sealed class RelationPickerDialogData(TableConfig sourceTable, object? current) { - public object? Object { get; set; } = current; +public sealed class RelationPickerDialogData(TableConfig sourceTable, List current, bool multiple) { + public List SelectedObjects { get; set; } = current; public TableConfig SourceTable { get; init; } = sourceTable; + public bool AllowMultiple { get; set; } = multiple; } \ No newline at end of file diff --git a/src/HopFrame.Web/wwwroot/hopframe.css b/src/HopFrame.Web/wwwroot/hopframe.css index 1db1c02..c713a09 100644 --- a/src/HopFrame.Web/wwwroot/hopframe.css +++ b/src/HopFrame.Web/wwwroot/hopframe.css @@ -21,6 +21,18 @@ padding: 0.5rem 1.5rem; } +.hopframe-listview { + background: padding-box linear-gradient(var(--neutral-fill-input-rest), var(--neutral-fill-input-rest)), border-box var(--neutral-stroke-input-rest); + border: calc(var(--stroke-width) * 1px) solid transparent; + border-radius: calc(var(--control-corner-radius) * 1px); + padding: 0 calc(var(--design-unit) * 2px + 1px); + margin-bottom: 4px; + display: flex; + align-items: center; + width: 100%; + height: 32px; +} + .hopframe-content .empty-content-row.empty-content-cell { border: none !important; } diff --git a/testing/HopFrame.Testing/Components/Pages/Home.razor b/testing/HopFrame.Testing/Components/Pages/Home.razor index 2e13e12..0a43ff8 100644 --- a/testing/HopFrame.Testing/Components/Pages/Home.razor +++ b/testing/HopFrame.Testing/Components/Pages/Home.razor @@ -1,5 +1,6 @@ @page "/" @using HopFrame.Testing.Models +@using Microsoft.EntityFrameworkCore Home diff --git a/testing/HopFrame.Testing/DatabaseContext.cs b/testing/HopFrame.Testing/DatabaseContext.cs index 73bf3c9..7abb091 100644 --- a/testing/HopFrame.Testing/DatabaseContext.cs +++ b/testing/HopFrame.Testing/DatabaseContext.cs @@ -12,8 +12,8 @@ public class DatabaseContext(DbContextOptions options) : DbCont base.OnModelCreating(modelBuilder); modelBuilder.Entity() - .HasOne() - .WithMany() + .HasOne(p => p.Author) + .WithMany(u => u.Posts) .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/testing/HopFrame.Testing/Models/Post.cs b/testing/HopFrame.Testing/Models/Post.cs index f575222..63a35b4 100644 --- a/testing/HopFrame.Testing/Models/Post.cs +++ b/testing/HopFrame.Testing/Models/Post.cs @@ -11,7 +11,7 @@ public class Post { [MaxLength(255)] public required string Caption { get; set; } - public required string Content { get; set; } + public required string? Content { get; set; } [ForeignKey("author")] public virtual required User Author { get; set; } diff --git a/testing/HopFrame.Testing/Models/User.cs b/testing/HopFrame.Testing/Models/User.cs index f1ae63d..22ea5de 100644 --- a/testing/HopFrame.Testing/Models/User.cs +++ b/testing/HopFrame.Testing/Models/User.cs @@ -11,6 +11,8 @@ public class User { public string? FirstName { get; set; } public string? LastName { get; set; } + public virtual List Posts { get; set; } = new(); + public override string ToString() { return Username; } diff --git a/testing/HopFrame.Testing/Program.cs b/testing/HopFrame.Testing/Program.cs index d3b7f37..f45ec71 100644 --- a/testing/HopFrame.Testing/Program.cs +++ b/testing/HopFrame.Testing/Program.cs @@ -1,3 +1,4 @@ +using System.Collections; using HopFrame.Testing; using Microsoft.FluentUI.AspNetCore.Components; using HopFrame.Testing.Components; @@ -41,6 +42,9 @@ builder.Services.AddHopFrame(options => { table.SetDescription("This table is used for user data store and user authentication"); table.SetViewPolicy("policy"); + + table.Property(u => u.Posts) + .FormatEach(post => post.Caption); }); context.Table() @@ -56,7 +60,7 @@ builder.Services.AddHopFrame(options => { context.Table() .Property(p => p.Caption) - .Validator(input => { + /*.Validator(input => { var errors = new List(); if (input is null) @@ -66,7 +70,7 @@ builder.Services.AddHopFrame(options => { errors.Add("Value can only be 10 characters long"); return errors; - }); + })*/; context.Table() .OrderIndex(-1);