Started working on editor dialog
All checks were successful
HopFrame CI / build (push) Successful in 46s
HopFrame CI / test (push) Successful in 57s

This commit is contained in:
2026-02-27 17:52:27 +01:00
parent e9e9fbf5e9
commit 0a00146a35
13 changed files with 402 additions and 28 deletions

View File

@@ -9,6 +9,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
public DbSet<Post> Posts { get; set; } public DbSet<Post> Posts { get; set; }
public DbSet<Typer> Typers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) { protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -22,5 +24,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
.HasMany(u => u.Posts) .HasMany(u => u.Posts)
.WithOne(p => p.Sender) .WithOne(p => p.Sender)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Typer>()
.HasKey(t => t.Id);
} }
} }

View File

@@ -0,0 +1,23 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace TestApplication.Models;
public class Typer {
[Key]
public Guid Id { get; set; }
public int Number { get; set; }
public bool Toggle { get; set; }
public DateTime DateTime { get; set; }
public DateOnly DateOnly { get; set; }
public TimeOnly TimeOnly { get; set; }
public ListSortDirection SortDirection { get; set; }
public string? Text { get; set; }
[EmailAddress]
public string? Mail { get; set; }
public string? LongText { get; set; }
public string? Password { get; set; }
public string? PhoneNumber { get; set; }
public List<string> List { get; set; } = new();
public List<ListSortDirection> SortDirections { get; set; } = new();
}

View File

@@ -1,3 +1,4 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.EFCore; using HopFrame.Core.EFCore;
using HopFrame.Web; using HopFrame.Web;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -30,6 +31,17 @@ builder.Services.AddHopFrame(config => {
config.Table<Post>(table => { config.Table<Post>(table => {
table.SetDescription("The posts dataset. It contains all posts sent via the application."); table.SetDescription("The posts dataset. It contains all posts sent via the application.");
}); });
config.Table<Typer>(table => {
table.Property(t => t.LongText)
.SetType(PropertyType.TextArea);
table.Property(t => t.Password)
.SetType(PropertyType.Password);
table.Property(t => t.PhoneNumber)
.SetType(PropertyType.PhoneNumber);
});
}); });
var app = builder.Build(); var app = builder.Build();

View File

@@ -47,7 +47,7 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : class
} }
/// <inheritdoc cref="Property(string)"/> /// <inheritdoc cref="Property(string)"/>
public PropertyConfigurator Property(Expression<Func<TModel, object>> 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);
@@ -58,7 +58,7 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : class
} }
/// <inheritdoc cref="TableConfig.PreferredProperty"/> /// <inheritdoc cref="TableConfig.PreferredProperty"/>
public TableConfigurator<TModel> SetPreferredProperty(Expression<Func<TModel, object>> propertyExpression) { public TableConfigurator<TModel> SetPreferredProperty(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);

View File

@@ -0,0 +1,23 @@
<MudDialog>
<TitleContent>
Confirm
</TitleContent>
<DialogContent>
Do you really want to delete this entry?
</DialogContent>
<DialogActions>
<MudButton OnClick="@(Cancel)">Cancel</MudButton>
<MudButton OnClick="@(Submit)" Color="Color.Error">Delete</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
}

View File

@@ -0,0 +1,35 @@
<MudDrawer
Anchor="Anchor.Right"
Elevation="1"
Variant="DrawerVariant.Temporary"
OverlayAutoClose="false"
@bind-Open="IsVisible"
Width="500px"
Breakpoint="Breakpoint.Always"
Style="@(IsVisible ? "display: flex" : "display: none")">
<MudStack Style="padding: 24px; height: 100%" Spacing="10">
<MudText Typo="Typo.h6">
@if (Mode == EditorMode.Creator) {
<span>Add @Config.TableType.Name</span>
}
else {
<span>Edit @Config.TableType.Name</span>
}
</MudText>
<MudStack Spacing="5" Style="overflow-y: auto">
<MudFocusTrap>
@foreach (var property in GetProperties()) {
<PropertyInput Config="property" Variant="Variant.Filled" />
}
</MudFocusTrap>
</MudStack>
<MudStack Row="true" Style="margin-top: auto">
<MudButton Color="Color.Primary" OnClick="@(Submit)">Save</MudButton>
<MudButton OnClick="@(Cancel)">Cancel</MudButton>
</MudStack>
</MudStack>
</MudDrawer>

View File

@@ -0,0 +1,63 @@
using HopFrame.Core.Configuration;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace HopFrame.Web.Components.Components;
public partial class Editor(IDialogService dialogs) : ComponentBase {
private enum EditorMode {
Editor,
Creator
}
[Parameter]
public required TableConfig Config { get; set; }
private bool IsVisible { get; set; }
private object? Entry { get; set; }
private EditorMode Mode { get; set; }
private TaskCompletionSource<object?> Completion { get; set; } = null!;
public Task<object?> Present(object? entry) {
Completion = new ();
Mode = entry is null ? EditorMode.Creator : EditorMode.Editor;
Entry = entry ?? Activator.CreateInstance(Config.TableType);
StateHasChanged();
IsVisible = true;
return Completion.Task;
}
private async Task Submit() {
var dialog = await dialogs.ShowAsync<ModifyConfirmationDialog>();
var result = await dialog.Result;
if (result is not null && !result.Canceled) {
ApplyChanges();
IsVisible = false;
Completion.SetResult(Entry);
}
}
private void Cancel() {
IsVisible = false;
Completion.SetResult(null);
}
private IEnumerable<PropertyConfig> GetProperties() {
var query = (IEnumerable<PropertyConfig>)Config.Properties;
if (Mode == EditorMode.Creator)
query = query.Where(p => p.Creatable);
return query.OrderBy(p => p.OrderIndex);
}
private void ApplyChanges() {
}
}

View File

@@ -0,0 +1,23 @@
<MudDialog>
<TitleContent>
Confirm
</TitleContent>
<DialogContent>
Do you really want to save the changes?
</DialogContent>
<DialogActions>
<MudButton OnClick="@(Cancel)">Cancel</MudButton>
<MudButton OnClick="@(Submit)" Color="Color.Primary">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
}

View File

@@ -0,0 +1,106 @@
@using HopFrame.Core.Configuration
@switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
case PropertyType.Numeric:
<MudNumericField
T="double"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
case PropertyType.Boolean:
<MudSwitch
T="bool"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"/>
break;
case PropertyType.DateTime:
<MudField Label="@Config.DisplayName" Variant="Variant.Outlined" Style="display: flex">
<MudStack Row="true">
<MudDatePicker
Label="Date"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
<MudTimePicker
Label="Time"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
</MudStack>
</MudField>
break;
case PropertyType.DateOnly:
<MudDatePicker
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
case PropertyType.TimeOnly:
<MudTimePicker
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
case PropertyType.Email:
<MudTextField
T="string"
InputType="InputType.Email"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
case PropertyType.Password:
<MudTextField
T="string"
InputType="InputType.Password"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
case PropertyType.PhoneNumber:
<MudTextField
T="string"
InputType="InputType.Telephone"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
default:
<MudTextField
T="string"
Label="@Config.DisplayName"
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
Disabled="@(!Config.Editable)"
Variant="Variant"/>
break;
}
@code {
[Parameter]
public object? Value { get; set; }
[Parameter]
public required PropertyConfig Config { get; set; }
[Parameter]
public Variant Variant { get; set; }
}

View File

@@ -1,6 +1,6 @@
@rendermode InteractiveServer @rendermode InteractiveServer
<MudTable ServerData="Reload" <MudTable ServerData="ReloadTable"
@ref="Manager" @ref="Manager"
Hover="true" Hover="true"
Breakpoint="Breakpoint.Sm" Breakpoint="Breakpoint.Sm"
@@ -25,7 +25,11 @@
Clearable="true" Clearable="true"
DebounceInterval="200" DebounceInterval="200"
OnDebounceIntervalElapsed="@(s => OnSearch(s))"/> OnDebounceIntervalElapsed="@(s => OnSearch(s))"/>
<MudButton EndIcon="@Icons.Material.Filled.Add" Style="margin-right: 0.5rem">Add</MudButton> <MudButton EndIcon="@Icons.Material.Filled.Add"
Style="margin-right: 0.5rem"
OnClick="@(OnAddClick)">
Add
</MudButton>
</MudStack> </MudStack>
</ToolBarContent> </ToolBarContent>
<HeaderContent> <HeaderContent>
@@ -45,13 +49,13 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
@foreach (var prop in OrderedProperties) { @foreach (var prop in OrderedProperties) {
<MudTd DataLabel="@prop.DisplayName" Style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 500px">@context[prop.Identifier]</MudTd> <MudTd DataLabel="@prop.DisplayName" Style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 500px">@context.Columns[prop.Identifier]</MudTd>
} }
<MudTd DataLabel="Actions"> <MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1"> <MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" /> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(async () => await OnEditClick(context.Entry))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(async () => await OnDeleteClick(context.Entry))" />
</MudStack> </MudStack>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>

View File

@@ -8,14 +8,28 @@ namespace HopFrame.Web.Components.Components;
public partial class Table(IEntityAccessor accessor, IConfigAccessor configAccessor) : ComponentBase { public partial class Table(IEntityAccessor accessor, IConfigAccessor configAccessor) : ComponentBase {
private readonly struct TableEntry {
public object Entry { get; init; }
public Dictionary<string, string> Columns { get; init; }
}
[Parameter] [Parameter]
public required TableConfig Config { get; set; } public required TableConfig Config { get; set; }
[Parameter]
public EventCallback OnAdd { get; set; }
[Parameter]
public EventCallback<object> OnDelete { get; set; }
[Parameter]
public EventCallback<object> OnEdit { get; set; }
private IHopFrameRepository Repository { get; set; } = null!; private IHopFrameRepository Repository { get; set; } = null!;
private PropertyConfig[] OrderedProperties { get; set; } = null!; private PropertyConfig[] OrderedProperties { get; set; } = null!;
private MudTable<Dictionary<string, string>> Manager { get; set; } = null!; private MudTable<TableEntry> Manager { get; set; } = null!;
private Dictionary<string, MudTableSortLabel<object>> SortDirections { get; set; } = new(); private Dictionary<string, MudTableSortLabel<object>> SortDirections { get; set; } = new();
@@ -38,22 +52,18 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
} }
} }
private List<Dictionary<string, string>> PrepareData(object[] entries) { public Task Reload() => Manager.ReloadServerData();
var list = new List<Dictionary<string, string>>();
private Dictionary<string, string> PrepareData(object entry) {
foreach (var entry in entries) { var dict = new Dictionary<string, string>();
var dict = new Dictionary<string, string>(); foreach (var prop in OrderedProperties) {
foreach (var prop in OrderedProperties) { dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty);
dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty);
}
list.Add(dict);
} }
return list; return dict;
} }
private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) { private async Task<TableData<TableEntry>> ReloadTable(TableState state, CancellationToken ct) {
IEnumerable<object> entries; IEnumerable<object> entries;
if (string.IsNullOrWhiteSpace(_searchText)) if (string.IsNullOrWhiteSpace(_searchText))
@@ -65,11 +75,14 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
var sortProp = Config.Properties.First(p => p.Identifier == _currentSort.Value.Key); var sortProp = Config.Properties.First(p => p.Identifier == _currentSort.Value.Key);
entries = accessor.SortDataByProperty(entries, sortProp, _currentSort.Value.Value == SortDirection.Descending); entries = accessor.SortDataByProperty(entries, sortProp, _currentSort.Value.Value == SortDirection.Descending);
} }
var data = PrepareData(entries.ToArray()); var data = entries.Select(e => new TableEntry {
Entry = e,
Columns = PrepareData(e)
});
var total = await Repository.CountAsync(ct); var total = await Repository.CountAsync(ct);
return new TableData<Dictionary<string, string>> { return new TableData<TableEntry> {
TotalItems = total, TotalItems = total,
Items = data Items = data
}; };
@@ -80,7 +93,11 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
await Manager.ReloadServerData(); await Manager.ReloadServerData();
} }
private bool _currentlyReloading;
private async Task OnSort(PropertyConfig property, SortDirection direction) { private async Task OnSort(PropertyConfig property, SortDirection direction) {
if (_currentlyReloading) return;
_currentlyReloading = true;
if (direction != SortDirection.None) { if (direction != SortDirection.None) {
foreach (var reference in SortDirections foreach (var reference in SortDirections
.Where(d => d.Key != property.Identifier)) { .Where(d => d.Key != property.Identifier)) {
@@ -98,5 +115,21 @@ public partial class Table(IEntityAccessor accessor, IConfigAccessor configAcces
} }
await Manager.ReloadServerData(); await Manager.ReloadServerData();
_currentlyReloading = false;
}
private async Task OnAddClick() {
if (OnAdd.HasDelegate)
await OnAdd.InvokeAsync();
}
private async Task OnEditClick(object entry) {
if (OnEdit.HasDelegate)
await OnEdit.InvokeAsync(entry);
}
private async Task OnDeleteClick(object entry) {
if (OnDelete.HasDelegate)
await OnDelete.InvokeAsync(entry);
} }
} }

View File

@@ -2,11 +2,19 @@
@using HopFrame.Web.Components.Components @using HopFrame.Web.Components.Components
@rendermode InteractiveServer @rendermode InteractiveServer
@layout HopFrameLayout @layout HopFrameLayout
@inherits CancellableComponent
<MudPopoverProvider /> <MudPopoverProvider />
<MudDialogProvider /> <MudDialogProvider />
<MudSnackbarProvider/> <MudSnackbarProvider />
<PageTitle>HopFrame - @Table.DisplayName</PageTitle> <PageTitle>HopFrame - @Table.DisplayName</PageTitle>
<Table Config="Table"></Table> <Editor Config="Table" @ref="EditorComponent"></Editor>
<Table
Config="Table"
OnAdd="@(OnAdd)"
OnEdit="@(OnEdit)"
OnDelete="@(OnDelete)"
@ref="TableComponent"></Table>

View File

@@ -1,16 +1,24 @@
using HopFrame.Core.Configuration; using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services; using HopFrame.Core.Services;
using HopFrame.Web.Components.Components;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace HopFrame.Web.Components.Pages; namespace HopFrame.Web.Components.Pages;
public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : ComponentBase { public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator, IDialogService dialogs, ISnackbar snackbar) : CancellableComponent {
private const int PerPage = 25;
[Parameter] [Parameter]
public string TableRoute { get; set; } = null!; public string TableRoute { get; set; } = null!;
public TableConfig Table { get; set; } = null!; private TableConfig Table { get; set; } = null!;
private IHopFrameRepository Repository { get; set; } = null!;
private Table TableComponent { get; set; } = null!;
private Editor EditorComponent { get; set; } = null!;
protected override void OnInitialized() { protected override void OnInitialized() {
base.OnInitialized(); base.OnInitialized();
@@ -23,5 +31,36 @@ public partial class TablePage(IConfigAccessor accessor, NavigationManager navig
} }
Table = table; Table = table;
Repository = accessor.LoadRepository(table);
snackbar.Configuration.PositionClass = Defaults.Classes.Position.BottomLeft;
}
private async Task OnAdd() {
var entry = await EditorComponent.Present(null);
if (entry is null) return;
await Repository.CreateGenericAsync(entry, TokenSource.Token);
await TableComponent.Reload();
snackbar.Add("Entry added", Severity.Success);
}
private async Task OnEdit(object entry) {
var newEntry = await EditorComponent.Present(entry);
if (newEntry is null) return;
await Repository.UpdateGenericAsync(newEntry, TokenSource.Token);
await TableComponent.Reload();
snackbar.Add("Entry updated", Severity.Success);
}
private async Task OnDelete(object entry) {
var dialog = await dialogs.ShowAsync<DeleteConfirmationDialog>();
var result = await dialog.Result;
if (result is not null && !result.Canceled) {
await Repository.DeleteGenericAsync(entry, TokenSource.Token);
await TableComponent.Reload();
snackbar.Add("Entry deleted", Severity.Success);
}
} }
} }