Added policy validation, ordering and virtual listing properties

This commit is contained in:
2025-01-16 20:23:28 +01:00
parent 4908947217
commit e9f686cf19
17 changed files with 321 additions and 93 deletions

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;
}