Feature/setup #54

Merged
leon.hoppe merged 17 commits from feature/setup into dev 2025-01-18 13:30:40 +01:00
17 changed files with 321 additions and 93 deletions
Showing only changes of commit e9f686cf19 - Show all commits

View File

@@ -9,13 +9,25 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="Added creation/modification confirmation">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/DefaultAuthHandler.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/PropertyConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/PropertyConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/ServiceCollectionExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/ServiceCollectionExtensions.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/IContextExplorer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/IContextExplorer.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/ITableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/ITableManager.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameHome.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameHome.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/Post.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/Post.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/User.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/User.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Services/AuthService.cs" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -58,8 +70,10 @@
}</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/26c9a2fb5243863babc926e4be763daf4128d4f97c4a769cdce1e2e3e5c532/FluentButton.razor.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/2751d5afefca5424bfc4b21347f581372f7a739c0ae4df661ea557fcb97ef20/EnumExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/60e7b22380df80ef6fefe43138047f49ec6eff4b25c12b42ce3d6ed5aac/MethodInvokerCommon.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/87c584767b46b5fd42769be76547105558e6690f785614efddca134b2d682/Type.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/adcd2c45092dd8e4fc412325c8adb75d6e7d8b3e90a9523f167583fb9c60/ServiceCollectionExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/b3ccb66df3646cb51df73ad51716136ebd2eefb4edb1308dd52a7e999582d59e/IBindableColumn.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d858ddb35a8e36df5573b7612542f9ad50f426b8ab43818587d1ac65fab14829/DatabaseGeneratedAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ece8533187fe96ce67b3ef1c9cc3502ef8da5510aadb132a9b21c5605d7c2119/PropertyColumn.cs" root0="FORCE_HIGHLIGHTING" />
@@ -78,24 +92,24 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;,
&quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/setup&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.environmentSetup&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
".NET Project.HopFrame.Testing.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "feature/setup",
"list.type.of.created.stylesheet": "CSS",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.environmentSetup",
"vue.rearranger.settings.migration": "true"
}
}</component>
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
@@ -149,7 +163,8 @@
<workItem from="1736875984621" duration="8464000" />
<workItem from="1736884461354" duration="1075000" />
<workItem from="1736962119221" duration="8119000" />
<workItem from="1737021098746" duration="21001000" />
<workItem from="1737021098746" duration="21112000" />
<workItem from="1737047730756" duration="7350000" />
</task>
<task id="LOCAL-00001" summary="Added basic configuration">
<option name="closed" value="true" />
@@ -231,7 +246,15 @@
<option name="project" value="LOCAL" />
<updated>1737040946489</updated>
</task>
<option name="localTasksCounter" value="11" />
<task id="LOCAL-00011" summary="Removed Template">
<option name="closed" value="true" />
<created>1737042229086</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1737042229086</updated>
</task>
<option name="localTasksCounter" value="12" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -251,6 +274,7 @@
<MESSAGE value="Added automatic relation mapping" />
<MESSAGE value="Added property validation" />
<MESSAGE value="Added creation/modification confirmation" />
<option name="LAST_COMMIT_MESSAGE" value="Added creation/modification confirmation" />
<MESSAGE value="Removed Template" />
<option name="LAST_COMMIT_MESSAGE" value="Removed Template" />
</component>
</project>

View File

@@ -4,7 +4,7 @@ namespace HopFrame.Core.Config;
public class DbContextConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; init; } = new();
public List<TableConfig> Tables { get; } = new();
public DbContextConfig(Type context) {
ContextType = context;
@@ -15,7 +15,7 @@ public class DbContextConfig {
var setType = typeof(DbSet<>).MakeGenericType(innerType);
if (property.PropertyType != setType) continue;
var table = new TableConfig(this, innerType, property.Name);
var table = new TableConfig(this, innerType, property.Name, Tables.Count);
Tables.Add(table);
}
}

View File

@@ -3,7 +3,7 @@ using System.Reflection;
namespace HopFrame.Core.Config;
public class PropertyConfig(PropertyInfo info, TableConfig table) {
public class PropertyConfig(PropertyInfo info, TableConfig table, int nthProperty) {
public PropertyInfo Info { get; } = info;
public TableConfig Table { get; } = table;
public string Name { get; set; } = info.Name;
@@ -19,6 +19,8 @@ public class PropertyConfig(PropertyInfo info, TableConfig table) {
public bool DisplayValue { get; set; } = true;
public bool IsRelation { get; set; }
public bool IsRequired { get; set; }
public bool IsListingProperty { get; set; }
public int Order { get; set; } = nthProperty;
}
public class PropertyConfig<TProp>(PropertyConfig config) {
@@ -84,4 +86,9 @@ public class PropertyConfig<TProp>(PropertyConfig config) {
InnerConfig.Validator = obj => validator.Invoke((TProp?)obj);
return this;
}
public PropertyConfig<TProp> OrderIndex(int index) {
InnerConfig.Order = index;
return this;
}
}

View File

@@ -9,20 +9,30 @@ public class TableConfig {
public Type TableType { get; }
public string PropertyName { get; }
public string DisplayName { get; set; }
public string? Description { get; set; }
public DbContextConfig ContextConfig { get; }
public bool Ignored { get; set; }
public int Order { get; set; }
internal bool Seeded { get; set; }
public string? ViewPolicy { get; set; }
public string? CreatePolicy { get; set; }
public string? UpdatePolicy { get; set; }
public string? DeletePolicy { get; set; }
public List<PropertyConfig> Properties { get; } = new();
public TableConfig(DbContextConfig config, Type tableType, string propertyName) {
public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) {
TableType = tableType;
PropertyName = propertyName;
ContextConfig = config;
DisplayName = PropertyName;
Order = nthTable;
foreach (var info in tableType.GetProperties()) {
var propConfig = new PropertyConfig(info, this);
var properties = tableType.GetProperties();
for (var i = 0; i < properties.Length; i++) {
var info = properties[i];
var propConfig = new PropertyConfig(info, this, i);
if (info.GetCustomAttributes(true).Any(a => a is DatabaseGeneratedAttribute)) {
propConfig.Creatable = false;
@@ -59,10 +69,55 @@ public class TableConfig<TModel>(TableConfig config) {
return this;
}
public PropertyConfig<string> AddListingProperty(string name, Func<TModel, string> template) {
var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count);
prop.Name = name;
prop.IsListingProperty = true;
prop.Formatter = obj => template.Invoke((TModel)obj);
InnerConfig.Properties.Add(prop);
return new PropertyConfig<string>(prop);
}
public TableConfig<TModel> AddListingProperty(string name, Func<TModel, string> template, Action<PropertyConfig<string>> configurator) {
var prop = AddListingProperty(name, template);
configurator.Invoke(prop);
return this;
}
public TableConfig<TModel> SetDisplayName(string name) {
InnerConfig.DisplayName = name;
return this;
}
public TableConfig<TModel> SetDescription(string description) {
InnerConfig.Description = description;
return this;
}
public TableConfig<TModel> OrderIndex(int index) {
InnerConfig.Order = index;
return this;
}
public TableConfig<TModel> SetViewPolicy(string policy) {
InnerConfig.ViewPolicy = policy;
return this;
}
public TableConfig<TModel> SetUpdatePolicy(string policy) {
InnerConfig.UpdatePolicy = policy;
return this;
}
public TableConfig<TModel> SetCreatePolicy(string policy) {
InnerConfig.CreatePolicy = policy;
return this;
}
public TableConfig<TModel> SetDeletePolicy(string policy) {
InnerConfig.DeletePolicy = policy;
return this;
}
internal static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
if (propertyLambda.Body is not MemberExpression member) {

View File

@@ -1,6 +1,7 @@
using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Core;
@@ -8,6 +9,7 @@ public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrameServices(this IServiceCollection services) {
services.AddTransient<IContextExplorer, ContextExplorer>();
services.TryAddTransient<IHopFrameAuthHandler, DefaultAuthHandler>();
return services;
}

View File

@@ -3,7 +3,7 @@
namespace HopFrame.Core.Services;
public interface IContextExplorer {
public IEnumerable<string> GetTableNames();
public IEnumerable<TableConfig> GetTables();
public TableConfig? GetTable(string tableDisplayName);
public TableConfig? GetTable(Type tableEntity);
public ITableManager? GetTableManager(string tablePropertyName);

View File

@@ -12,5 +12,5 @@ public interface ITableManager {
public Task AddItem(object item);
public Task RevertChanges(object item);
public string DisplayProperty(object? item, PropertyInfo info, TableConfig? tableConfig);
public string DisplayProperty(object? item, PropertyConfig prop, TableConfig? tableConfig);
}

View File

@@ -5,11 +5,11 @@ using Microsoft.Extensions.Logging;
namespace HopFrame.Core.Services.Implementations;
internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider provider, ILogger<ContextExplorer> logger) : IContextExplorer {
public IEnumerable<string> GetTableNames() {
public IEnumerable<TableConfig> GetTables() {
foreach (var context in config.Contexts) {
foreach (var table in context.Tables) {
if (table.Ignored) continue;
yield return table.DisplayName;
yield return table;
}
}
}
@@ -60,6 +60,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
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;
@@ -67,6 +68,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
foreach (var property in entity.GetProperties()) {
var propConfig = table.Properties
.Where(prop => !prop.IsListingProperty)
.SingleOrDefault(prop => prop.Info == property.PropertyInfo);
if (propConfig is null) continue;
propConfig.IsRequired = !property.IsNullable;

View File

@@ -1,12 +1,10 @@
using HopFrame.Core.Services;
namespace HopFrame.Core.Services.Implementations;
namespace HopFrame.Testing.Services;
public class AuthService : IHopFrameAuthHandler {
internal sealed class DefaultAuthHandler : IHopFrameAuthHandler {
public Task<bool> IsAuthenticatedAsync(string? policy) {
return Task.FromResult(true);
}
public Task<string> GetCurrentUserDisplayNameAsync() {
return Task.FromResult("Leon Hoppe");
return Task.FromResult(string.Empty);
}
}

View File

@@ -67,10 +67,11 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
return false;
}
public string DisplayProperty(object? item, PropertyInfo info, TableConfig? tableConfig) {
public string DisplayProperty(object? item, PropertyConfig prop, TableConfig? tableConfig) {
if (item is null) return string.Empty;
var prop = tableConfig?.Properties.Find(prop => prop.Info.Name == info.Name);
if (prop is null) return item.ToString() ?? string.Empty;
if (prop.IsListingProperty)
return prop.Formatter!.Invoke(item);
var propValue = prop.Info.GetValue(item);
if (propValue is null)
@@ -83,14 +84,17 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
if (prop.DisplayedProperty is null) {
var key = prop.Info.PropertyType
.GetProperties()
.Where(p => p.GetCustomAttributes(true).Any(a => a is KeyAttribute))
.FirstOrDefault();
.FirstOrDefault(p => p.GetCustomAttributes(true).Any(a => a is KeyAttribute));
return key?.GetValue(propValue)?.ToString() ?? propValue.ToString() ?? string.Empty;
}
var innerConfig = explorer.GetTable(propValue.GetType());
return DisplayProperty(propValue, prop.DisplayedProperty, innerConfig);
var innerProp = innerConfig!.Properties
.SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsListingProperty);
if (innerProp is null) return propValue.ToString() ?? string.Empty;
return DisplayProperty(propValue, innerProp, innerConfig);
}
private IQueryable<TModel> IncludeForgeinKeys(IQueryable<TModel> query) {

View File

@@ -8,7 +8,7 @@
@using Microsoft.EntityFrameworkCore.Internal
<FluentDialogBody>
@foreach (var property in Content.Config.Properties) {
@foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) {
if (!_currentlyEditing && !property.Creatable) continue;
<div style="margin-bottom: 20px">
@@ -21,10 +21,10 @@
Required="@property.IsRequired"
ReadOnly="true"
Style="width: 100%"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Text))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
</div>
<div style="display: flex; gap: 5px; margin-bottom: 4px">
<FluentButton OnClick="() => SetPropertyValue(property, null, InputType.Relation)" Disabled="@(!property.Editable)">
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Relation)" Disabled="@(!property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton>
<FluentButton OnClick="async () => await OpenRelationalPicker(property)" Disabled="@(!property.Editable)">
@@ -41,7 +41,7 @@
Style="width: 100%;"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Number))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Number))" />
}
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.Boolean) {
<FluentSwitch
@@ -49,24 +49,24 @@
Value="GetPropertyValue<bool>(property)"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Switch))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Switch))" />
}
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.DateTime) {
<div style="display: flex; gap: 10px">
<div style="display: flex; gap: 5px">
<div style="display: flex; flex-direction: column; width: 100%">
<FluentDatePicker
Label="@property.Name"
Value="GetPropertyValue<DateTime>(property)"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Date))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
</div>
<div style="display: flex; flex-direction: column; justify-content: flex-end">
<FluentTimePicker
Value="GetPropertyValue<DateTime>(property)"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Time))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
</div>
</div>
}
@@ -77,7 +77,7 @@
Style="width: 100%"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Date))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
}
else if (property.Info.PropertyType == typeof(TimeOnly)) {
<FluentTimePicker
@@ -86,7 +86,7 @@
Style="width: 100%"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Time))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
}
else if (property.Info.PropertyType.IsEnum) {
<FluentSelect
@@ -98,7 +98,28 @@
Height="250px"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Enum))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
}
else if (property.Info.PropertyType.IsNullableEnum()) {
<div style="display: flex; gap: 5px; align-items: flex-end">
<div style="flex-grow: 1">
<FluentSelect
TOption="string"
Label="@property.Name"
Items="@(["", ..Enum.GetNames(Nullable.GetUnderlyingType(property.Info.PropertyType)!)])"
Value="@(GetPropertyValue<string>(property))"
Style="width: 100%"
Height="250px"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
</div>
<div style="display: flex; gap: 5px">
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Enum)" Disabled="@(!property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton>
</div>
</div>
}
else {
<FluentTextField
@@ -107,7 +128,7 @@
Style="width: 100%;"
Disabled="@(!property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Text))" />
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
}
@foreach (var error in _validationErrors[property.Info.Name]) {
@@ -119,6 +140,7 @@
@inject IContextExplorer Explorer
@inject IDialogService Dialogs
@inject IHopFrameAuthHandler Handler
@code {
[Parameter]
@@ -141,6 +163,7 @@
Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType);
foreach (var property in Content.Config.Properties) {
if (property.IsListingProperty) continue;
_validationErrors.Add(property.Info.Name, []);
}
}
@@ -157,15 +180,18 @@
return (TValue)value;
if (typeof(TValue) == typeof(string))
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config.Info, Content.Config);
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, Content.Config);
return (TValue)Convert.ChangeType(value, typeof(TValue));
}
private void SetPropertyValue(PropertyConfig config, object? value, InputType senderType) {
private async Task SetPropertyValue(PropertyConfig config, object? value, InputType senderType) {
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
return;
object? result = null;
if (value is not null) {
if (value is not null && config.Parser is null) {
switch (senderType) {
case InputType.Number:
result = Convert.ChangeType(value, config.Info.PropertyType);
@@ -180,7 +206,10 @@
break;
case InputType.Enum:
result = Enum.Parse(config.Info.PropertyType, (string)value);
var type = Nullable.GetUnderlyingType(config.Info.PropertyType);
if (type is not null && string.IsNullOrEmpty((string)value)) break;
type ??= config.Info.PropertyType;
result = Enum.Parse(type, (string)value);
break;
case InputType.Date:
@@ -218,6 +247,9 @@
}
private async Task OpenRelationalPicker(PropertyConfig config) {
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
return;
var relationTable = Explorer.GetTable(config.Info.PropertyType);
if (relationTable is null) return;
@@ -227,11 +259,16 @@
if (result.Cancelled) return;
var data = (RelationPickerDialogData)result.Data!;
SetPropertyValue(config, data.Object, InputType.Relation);
await SetPropertyValue(config, data.Object, InputType.Relation);
}
private async Task<bool> ValidateInputs() {
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
return false;
foreach (var property in Content.Config.Properties) {
if (property.IsListingProperty) continue;
var errorList = _validationErrors[property.Info.Name];
errorList.Clear();
var value = property.Info.GetValue(Content.CurrentObject);
@@ -242,7 +279,7 @@
}
if (value is null && property.IsRequired)
errorList.Add("Value cannot be null");
errorList.Add($"{property.Name} is required");
}
StateHasChanged();

View File

@@ -1,4 +1,6 @@
@using HopFrame.Core.Services
@using HopFrame.Core.Config
@using HopFrame.Core.Services
<FluentAppBar Orientation="Orientation.Vertical" Style="background-color: var(--neutral-layer-2); height: auto">
<FluentAppBarItem Href="/admin"
Match="NavLinkMatch.All"
@@ -9,7 +11,7 @@
<br>
@foreach (var table in Explorer.GetTableNames()) {
@foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) {
<FluentAppBarItem Href="@("/admin/" + table.ToLower())"
Match="NavLinkMatch.All"
IconActive="new Icons.Filled.Size24.Database()"
@@ -20,3 +22,18 @@
</FluentAppBar>
@inject IContextExplorer Explorer
@inject IHopFrameAuthHandler Handler
@code {
private readonly List<TableConfig> _tables = [];
protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) {
if (table.Ignored) continue;
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table);
}
}
}

View File

@@ -1,8 +1,45 @@
@page "/admin"
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@layout HopFrameLayout
<h3>HopFrameHome</h3>
<PageTitle>HopFrame</PageTitle>
<div style="padding: 1.5rem 1.5rem;">
<h2>Tables</h2>
<FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem">
@foreach (var table in _tables.OrderBy(t => t.Order)) {
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)">
<h3 style="margin-bottom: 0;">@table.DisplayName</h3>
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@table.ViewPolicy</FluentLabel>
<span>@table.Description</span>
<FluentSpacer />
<div style="display: flex">
<FluentSpacer/>
<a href="@("/admin/" + table.DisplayName.ToLower())" style="display: inline-block">
<FluentButton>Open</FluentButton>
</a>
</div>
</FluentCard>
}
</FluentStack>
</div>
@inject IContextExplorer Explorer
@inject IHopFrameAuthHandler Handler
@code {
private readonly List<TableConfig> _tables = [];
protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) {
if (table.Ignored) continue;
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table);
}
}
}

View File

@@ -9,23 +9,29 @@
@using Microsoft.JSInterop
@using Microsoft.EntityFrameworkCore
@if (!DisplaySelection) {
<PageTitle>@_config?.DisplayName</PageTitle>
}
<FluentDialogProvider />
<div style="display: flex; flex-direction: column; height: 100%">
<FluentToolbar Class="hopframe-toolbar">
<h3>@_config?.DisplayName</h3>
<FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload"
Loading="_loading"
Style="margin-left: 10px">
Refresh
</FluentButton>
@if (!DisplaySelection) {
<FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload"
Loading="_loading"
Style="margin-left: 10px">
Refresh
</FluentButton>
}
<FluentSpacer />
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" />
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" />
@if (!DisplaySelection) {
@if (!DisplaySelection && _hasCreatePolicy) {
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
}
</FluentToolbar>
@@ -47,25 +53,30 @@
<div style="flex-grow: 1">
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()">
@foreach (var property in _config!.Properties.Where(prop => prop.List)) {
@foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) {
<PropertyColumn
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property.Info, _config)"
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property, _config)"
Style="min-width: max-content; height: 44px;"
Sortable="@property.Sortable"/>
}
@if (DisplayActions) {
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
var dataIndex = 0;
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); }
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(currentElement); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
</FluentButton>
@if (_hasUpdatePolicy) {
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(currentElement); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())" />
</FluentButton>
}
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
</FluentButton>
@if (_hasDeletePolicy) {
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning" />
</FluentButton>
}
@{
dataIndex++;
@@ -116,6 +127,7 @@
@inject NavigationManager Navigator
@inject IJSRuntime Js
@inject IDialogService Dialogs
@inject IHopFrameAuthHandler Handler
@code {
@@ -142,7 +154,10 @@
private int _totalPages;
private string? _searchTerm;
private bool _loading;
private int _selectedIndex = -1;
private bool _hasUpdatePolicy;
private bool _hasDeletePolicy;
private bool _hasCreatePolicy;
protected override void OnInitialized() {
_config ??= Explorer.GetTable(TableDisplayName);
@@ -153,6 +168,15 @@
}
protected override async Task OnInitializedAsync() {
if (!await Handler.IsAuthenticatedAsync(_config?.ViewPolicy)) {
Navigator.NavigateTo("/admin", true);
return;
}
_hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy);
_hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy);
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync();
_totalPages = await _manager.TotalPages(PerPage);
@@ -201,6 +225,11 @@
}
private async Task DeleteEntry(object element) {
if (!await Handler.IsAuthenticatedAsync(_config?.DeletePolicy)) {
Navigator.NavigateTo("/admin", true);
return;
}
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
var result = await dialog.Result;
if (result.Cancelled) return;
@@ -210,6 +239,11 @@
}
private async Task CreateOrEdit(object? element) {
if (!await Handler.IsAuthenticatedAsync(element is null ? _config?.CreatePolicy : _config?.UpdatePolicy)) {
Navigator.NavigateTo("/admin", true);
return;
}
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
TrapFocus = false
});

View File

@@ -1,20 +1,22 @@
using HopFrame.Core;
using HopFrame.Core.Config;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web;
public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null) {
var config = new HopFrameConfig();
configurator.Invoke(new HopFrameConfigurator(config));
return AddHopFrame(services, config);
return AddHopFrame(services, config, fluentUiLibraryConfiguration);
}
public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config) {
public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config, LibraryConfiguration? fluentUiLibraryConfiguration = null) {
services.AddSingleton(config);
services.AddHopFrameServices();
services.AddFluentUIComponents(fluentUiLibraryConfiguration);
return services;
}

View File

@@ -25,4 +25,6 @@ public class Post {
public TimeOnly At { get; set; }
public ListSortDirection Type { get; set; }
public TypeCode? TypeCode { get; set; }
}

View File

@@ -1,9 +1,7 @@
using HopFrame.Core.Services;
using HopFrame.Testing;
using Microsoft.FluentUI.AspNetCore.Components;
using HopFrame.Testing.Components;
using HopFrame.Testing.Models;
using HopFrame.Testing.Services;
using HopFrame.Web;
using HopFrame.Web.Components.Pages;
using Microsoft.EntityFrameworkCore;
@@ -20,21 +18,29 @@ builder.Services.AddDbContext<DatabaseContext>(options => {
});
builder.Services.AddHopFrame(options => {
options.DisplayUserInfo(false);
options.AddDbContext<DatabaseContext>(context => {
context.Table<User>(table => {
table.Property(u => u.Password)
.ValueParser(pwd => pwd + "-edited");
table.Property(u => u.FirstName)
.SetDisplayName("First Name");
.List(false);
table.Property(u => u.LastName)
.SetDisplayName("Last Name");
.List(false);
table.Property(u => u.Id)
.Sortable(false);
.Sortable(false)
.OrderIndex(3);
table.AddListingProperty("Name", user => $"{user.FirstName} {user.LastName}")
.OrderIndex(2);
table.SetDisplayName("Benutzer");
table.SetDescription("This table is used for user data store and user authentication");
table.SetViewPolicy("policy");
});
context.Table<Post>()
@@ -61,11 +67,12 @@ builder.Services.AddHopFrame(options => {
return errors;
});
context.Table<Post>()
.OrderIndex(-1);
});
});
builder.Services.AddTransient<IHopFrameAuthHandler, AuthService>();
var app = builder.Build();
// Configure the HTTP request pipeline.