Feature/setup #54
31
.idea/.idea.HopFrame/.idea/workspace.xml
generated
31
.idea/.idea.HopFrame/.idea/workspace.xml
generated
@@ -9,15 +9,17 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
|
||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="Added reload button and animation">
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Models/RelationPickerDialogData.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/HopFrameConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/HopFrameConfig.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/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/TableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameListView.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameListView.razor" 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/Pages/HopFrameListView.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameListView.razor.css" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/DatabaseContext.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/DatabaseContext.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" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
@@ -60,11 +62,13 @@
|
||||
}
|
||||
}</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/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/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" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ee4d234452e240d83e3de396c2e85cbf9ac9fb9add618b955eea196c81aaf8/IDialogContentComponent.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ff37d54b3bf4d2756237fb789635831532603376e940f63d634b869d26d74c/Regular16.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
</component>
|
||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||
@@ -150,7 +154,7 @@
|
||||
<workItem from="1736875984621" duration="8464000" />
|
||||
<workItem from="1736884461354" duration="1075000" />
|
||||
<workItem from="1736962119221" duration="8119000" />
|
||||
<workItem from="1737021098746" duration="1810000" />
|
||||
<workItem from="1737021098746" duration="13969000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Added basic configuration">
|
||||
<option name="closed" value="true" />
|
||||
@@ -192,7 +196,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736970238802</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="6" />
|
||||
<task id="LOCAL-00006" summary="Added reload button and animation">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737023058093</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737023058093</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="7" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -207,6 +219,7 @@
|
||||
<MESSAGE value="Added database loading logic" />
|
||||
<MESSAGE value="Started working on listing page" />
|
||||
<MESSAGE value="Added entry saving support" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Added entry saving support" />
|
||||
<MESSAGE value="Added reload button and animation" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Added reload button and animation" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -3,8 +3,9 @@ using System.Reflection;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class PropertyConfig(PropertyInfo info) {
|
||||
public PropertyInfo Info { get; init; } = info;
|
||||
public class PropertyConfig(PropertyInfo info, TableConfig table) {
|
||||
public PropertyInfo Info { get; } = info;
|
||||
public TableConfig Table { get; } = table;
|
||||
public string Name { get; set; } = info.Name;
|
||||
public bool List { get; set; } = true;
|
||||
public bool Sortable { get; set; } = true;
|
||||
@@ -17,6 +18,7 @@ public class PropertyConfig(PropertyInfo info) {
|
||||
public bool Creatable { get; set; } = true;
|
||||
public bool DisplayValue { get; set; } = true;
|
||||
public bool IsRelation { get; set; }
|
||||
public bool IsPrimaryKey { get; set; }
|
||||
}
|
||||
|
||||
public class PropertyConfig<TProp>(PropertyConfig config) {
|
||||
|
||||
@@ -19,7 +19,7 @@ public class TableConfig {
|
||||
ContextConfig = config;
|
||||
|
||||
foreach (var info in tableType.GetProperties()) {
|
||||
var propConfig = new PropertyConfig(info);
|
||||
var propConfig = new PropertyConfig(info, this);
|
||||
|
||||
if (info.GetCustomAttributes(true).Any(a => a is DatabaseGeneratedAttribute)) {
|
||||
propConfig.Creatable = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@implements IDialogContentComponent<EditorDialogData>
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@@ -11,7 +12,28 @@
|
||||
if (!_currentlyEditing && !property.Creatable) continue;
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
@if (property.Info.PropertyType.IsNumeric()) {
|
||||
@if (property.IsRelation) {
|
||||
<div style="display: flex; gap: 5px; align-items: flex-end">
|
||||
<div style="flex-grow: 1">
|
||||
<FluentTextField
|
||||
Label="@property.Name"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Disabled="@(!property.Editable)"
|
||||
ReadOnly="true"
|
||||
Style="width: 100%"
|
||||
ValueChanged="@(v => SetPropertyValue(property, v, InputType.Text))" />
|
||||
</div>
|
||||
<div style="display: flex; gap: 5px; margin-bottom: 4px">
|
||||
<FluentButton OnClick="() => SetPropertyValue(property, null, InputType.Relation)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
<FluentButton OnClick="async () => await OpenRelationalPicker(property)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (property.Info.PropertyType.IsNumeric()) {
|
||||
<FluentNumberField
|
||||
TValue="double"
|
||||
Label="@property.Name"
|
||||
@@ -84,6 +106,7 @@
|
||||
</FluentDialogBody>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject IDialogService Dialogs
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
@@ -98,14 +121,13 @@
|
||||
protected override void OnInitialized() {
|
||||
_currentlyEditing = Content.CurrentObject is not null;
|
||||
Dialog.Instance.Parameters.Title = (_currentlyEditing ? "Edit " : "Add ") + Content.Config.TableType.Name;
|
||||
Dialog.Instance.Parameters.PreventScroll = true;
|
||||
Dialog.Instance.Parameters.Width = "500px";
|
||||
Dialog.Instance.Parameters.PrimaryAction = "Save";
|
||||
_manager = Explorer.GetTableManager(Content.Config.PropertyName);
|
||||
Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType);
|
||||
}
|
||||
|
||||
private TValue? GetPropertyValue<TValue>(PropertyConfig config) { //TODO: handle relational types
|
||||
private TValue? GetPropertyValue<TValue>(PropertyConfig config) {
|
||||
if (!config.DisplayValue) return default;
|
||||
if (Content.CurrentObject is null) return default;
|
||||
var value = config.Info.GetValue(Content.CurrentObject);
|
||||
@@ -171,24 +193,42 @@
|
||||
else result = (TimeOnly)value;
|
||||
break;
|
||||
|
||||
case InputType.Relation:
|
||||
result = value;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(senderType), senderType, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.Parser is not null) {
|
||||
result = config.Parser(result!.ToString()!);
|
||||
if (config.Parser is not null && result is not null) {
|
||||
result = config.Parser(result.ToString()!);
|
||||
}
|
||||
|
||||
config.Info.SetValue(Content.CurrentObject, result);
|
||||
}
|
||||
|
||||
private async Task OpenRelationalPicker(PropertyConfig config) {
|
||||
var relationTable = Explorer.GetTable(config.Info.PropertyType);
|
||||
if (relationTable is null) return;
|
||||
|
||||
var currentValue = config.Info.GetValue(Content.CurrentObject);
|
||||
var dialog = await Dialogs.ShowDialogAsync<HopFrameRelationPicker>(new RelationPickerDialogData(relationTable, currentValue), new DialogParameters());
|
||||
var result = await dialog.Result;
|
||||
if (result.Cancelled) return;
|
||||
|
||||
var data = (RelationPickerDialogData)result.Data!;
|
||||
SetPropertyValue(config, data.Object, InputType.Relation);
|
||||
}
|
||||
|
||||
private enum InputType {
|
||||
Number,
|
||||
Switch,
|
||||
Date,
|
||||
Time,
|
||||
Enum,
|
||||
Text
|
||||
Text,
|
||||
Relation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@implements IDialogContentComponent<RelationPickerDialogData>
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using HopFrame.Web.Models
|
||||
@using HopFrame.Web.Components.Pages
|
||||
|
||||
<FluentDialogBody Style="overflow-x: auto">
|
||||
<HopFrameTablePage DisplayActions="false" DisplaySelection="true" TableName="@Content.SourceTable.PropertyName" PerPage="15" DialogData="Content" />
|
||||
</FluentDialogBody>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required RelationPickerDialogData Content { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public required FluentDialog Dialog { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
Dialog.Instance.Parameters.Title = $"Select {Content.SourceTable.TableType.Name}";
|
||||
Dialog.Instance.Parameters.Width = "90vw";
|
||||
Dialog.Instance.Parameters.Height = "90vh";
|
||||
Dialog.Instance.Parameters.PrimaryAction = "Assign";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
@page "/admin/{TableName}"
|
||||
@layout HopFrameLayout
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using HopFrame.Web.Models
|
||||
@using Microsoft.JSInterop
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<FluentDialogProvider />
|
||||
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<FluentToolbar Class="hopframe-toolbar">
|
||||
<h3>@_config?.PropertyName</h3>
|
||||
<FluentButton
|
||||
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
|
||||
OnClick="Reload"
|
||||
Loading="_loading"
|
||||
Style="margin-left: 10px">
|
||||
Refresh
|
||||
</FluentButton>
|
||||
|
||||
<FluentSpacer />
|
||||
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" />
|
||||
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
|
||||
</FluentToolbar>
|
||||
<FluentProgress Visible="_loading" Width="100%" />
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1">
|
||||
<FluentDataGrid Items="_currentlyDisplayedModels?.AsQueryable()">
|
||||
@{ var dataIndex = 0; }
|
||||
@foreach (var property in _config!.Properties.Where(prop => prop.List)) {
|
||||
<PropertyColumn
|
||||
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property.Info, _config)"
|
||||
Style="min-width: max-content; min-height: 43px;"
|
||||
Sortable="@property.Sortable"
|
||||
/>
|
||||
}
|
||||
|
||||
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 43px; 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>
|
||||
|
||||
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
|
||||
</FluentButton>
|
||||
|
||||
@{
|
||||
dataIndex++;
|
||||
dataIndex %= 20;
|
||||
}
|
||||
</TemplateColumn>
|
||||
</FluentDataGrid>
|
||||
|
||||
@if (_totalPages > 1) {
|
||||
<div class="hopframe-paginator">
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
|
||||
<span>Page</span>
|
||||
|
||||
<FluentSelect TOption="int"
|
||||
Items="Enumerable.Range(0, _totalPages)"
|
||||
OptionValue="@(p => p.ToString())"
|
||||
OptionText="@(p => (p + 1).ToString())"
|
||||
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))"
|
||||
Width="max-content" SelectedOption="@_currentPage"/>
|
||||
|
||||
<span>of @_totalPages</span>
|
||||
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function removeBg() {
|
||||
const elements = document.querySelectorAll(".col-sort-button");
|
||||
const style = new CSSStyleSheet();
|
||||
style.replaceSync(".control { background: none !important; }");
|
||||
elements.forEach(e => e?.shadowRoot?.adoptedStyleSheets?.push(style));
|
||||
}
|
||||
|
||||
removeBg();
|
||||
</script>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject NavigationManager Navigator
|
||||
@inject IJSRuntime Js
|
||||
@inject IDialogService Dialogs
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required string TableName { get; set; }
|
||||
|
||||
private TableConfig? _config;
|
||||
private ITableManager? _manager;
|
||||
|
||||
private IEnumerable<object>? _currentlyDisplayedModels;
|
||||
private int _currentPage;
|
||||
private int _totalPages;
|
||||
private string? _searchTerm;
|
||||
private bool _loading;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_config ??= Explorer.GetTable(TableName);
|
||||
|
||||
if (_config is null) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
|
||||
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage).ToArrayAsync();
|
||||
_totalPages = await _manager.TotalPages();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
try {
|
||||
await Js.InvokeVoidAsync("removeBg");
|
||||
}
|
||||
catch (Exception) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_searchCancel.Dispose();
|
||||
}
|
||||
|
||||
private CancellationTokenSource _searchCancel = new();
|
||||
private async Task OnSearch(ChangeEventArgs eventArgs) {
|
||||
await _searchCancel.CancelAsync();
|
||||
_searchTerm = eventArgs.Value?.ToString();
|
||||
if (_searchTerm is null) return;
|
||||
_searchCancel = new();
|
||||
|
||||
await Task.Delay(500, _searchCancel.Token);
|
||||
(var query, _totalPages) = await _manager!.Search(_searchTerm);
|
||||
_currentlyDisplayedModels = query.ToArray();
|
||||
}
|
||||
|
||||
private async Task Reload() {
|
||||
_loading = true;
|
||||
if (!string.IsNullOrEmpty(_searchTerm)) {
|
||||
(var query, _totalPages) = await _manager!.Search(_searchTerm);
|
||||
_currentlyDisplayedModels = query.ToArray();
|
||||
}
|
||||
else {
|
||||
await OnInitializedAsync();
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task ChangePage(int page) {
|
||||
if (page < 0 || page > _totalPages - 1) return;
|
||||
_currentPage = page;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task DeleteEntry(object element) {
|
||||
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
|
||||
var result = await dialog.Result;
|
||||
if (result.Cancelled) return;
|
||||
|
||||
await _manager!.DeleteItem(element);
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task CreateOrEdit(object? element) {
|
||||
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new());
|
||||
var result = await panel.Result;
|
||||
var data = result.Data as EditorDialogData;
|
||||
|
||||
if (result.Cancelled) {
|
||||
if (data?.CurrentObject is not null)
|
||||
await _manager!.RevertChanges(data.CurrentObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element is null)
|
||||
await _manager!.AddItem(data!.CurrentObject!);
|
||||
else
|
||||
await _manager!.EditItem(data!.CurrentObject!);
|
||||
|
||||
await Reload();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hopframe-paginator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-block: 20px;
|
||||
}
|
||||
232
src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
Normal file
232
src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
Normal file
@@ -0,0 +1,232 @@
|
||||
@page "/admin/{TableName}"
|
||||
@layout HopFrameLayout
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using HopFrame.Web.Models
|
||||
@using Microsoft.JSInterop
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<FluentDialogProvider />
|
||||
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<FluentToolbar Class="hopframe-toolbar">
|
||||
<h3>@_config?.PropertyName</h3>
|
||||
<FluentButton
|
||||
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
|
||||
OnClick="Reload"
|
||||
Loading="_loading"
|
||||
Style="margin-left: 10px">
|
||||
Refresh
|
||||
</FluentButton>
|
||||
|
||||
<FluentSpacer />
|
||||
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" />
|
||||
|
||||
@if (!DisplaySelection) {
|
||||
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
|
||||
}
|
||||
</FluentToolbar>
|
||||
<FluentProgress Visible="_loading" Width="100%" />
|
||||
|
||||
<div style="display: flex; overflow-y: auto; flex-grow: 1">
|
||||
@if (DisplaySelection) {
|
||||
<div style="margin-top: calc(44px - (var(--stroke-width) * 1px)); border-top: calc(var(--stroke-width)* 1px) solid var(--neutral-stroke-divider-rest);">
|
||||
@foreach (var model in _currentlyDisplayedModels) {
|
||||
<div class="hopframe-radio">
|
||||
<FluentRadioGroup TValue="int" Value="@(DialogData!.Object == model ? 1 : 0)">
|
||||
<FluentRadio Value="0" Style="display: none" />
|
||||
<FluentRadio Value="1" @onclick="() => DialogData!.Object = model" Style="width: 20px"/>
|
||||
</FluentRadioGroup>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="flex-grow: 1">
|
||||
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()">
|
||||
@foreach (var property in _config!.Properties.Where(prop => prop.List)) {
|
||||
<PropertyColumn
|
||||
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property.Info, _config)"
|
||||
Style="min-width: max-content; height: 44px;"
|
||||
Sortable="@property.Sortable"/>
|
||||
}
|
||||
|
||||
@if (DisplayActions) {
|
||||
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>
|
||||
|
||||
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
|
||||
</FluentButton>
|
||||
|
||||
@{
|
||||
dataIndex++;
|
||||
dataIndex %= 20;
|
||||
}
|
||||
</TemplateColumn>
|
||||
}
|
||||
</FluentDataGrid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_totalPages > 1) {
|
||||
<div class="hopframe-paginator">
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
|
||||
<span>Page</span>
|
||||
|
||||
<FluentSelect TOption="int"
|
||||
Items="Enumerable.Range(0, _totalPages)"
|
||||
OptionValue="@(p => p.ToString())"
|
||||
OptionText="@(p => (p + 1).ToString())"
|
||||
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))"
|
||||
Width="max-content" SelectedOption="@_currentPage"/>
|
||||
|
||||
<span>of @_totalPages</span>
|
||||
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function removeBg() {
|
||||
const elements = document.querySelectorAll(".col-sort-button");
|
||||
const style = new CSSStyleSheet();
|
||||
style.replaceSync(".control { background: none !important; }");
|
||||
elements.forEach(e => e?.shadowRoot?.adoptedStyleSheets?.push(style));
|
||||
}
|
||||
|
||||
removeBg();
|
||||
</script>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject NavigationManager Navigator
|
||||
@inject IJSRuntime Js
|
||||
@inject IDialogService Dialogs
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required string TableName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool DisplaySelection { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool DisplayActions { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public RelationPickerDialogData? DialogData { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PerPage { get; set; } = 20;
|
||||
|
||||
private TableConfig? _config;
|
||||
private ITableManager? _manager;
|
||||
|
||||
private IEnumerable<object> _currentlyDisplayedModels = [];
|
||||
private int _currentPage;
|
||||
private int _totalPages;
|
||||
private string? _searchTerm;
|
||||
private bool _loading;
|
||||
private int _selectedIndex = -1;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_config ??= Explorer.GetTable(TableName);
|
||||
|
||||
if (_config is null) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
|
||||
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync();
|
||||
_totalPages = await _manager.TotalPages(PerPage);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
try {
|
||||
await Js.InvokeVoidAsync("removeBg");
|
||||
}
|
||||
catch (Exception) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_searchCancel.Dispose();
|
||||
}
|
||||
|
||||
private CancellationTokenSource _searchCancel = new();
|
||||
private async Task OnSearch(ChangeEventArgs eventArgs) {
|
||||
await _searchCancel.CancelAsync();
|
||||
_searchTerm = eventArgs.Value?.ToString();
|
||||
if (_searchTerm is null) return;
|
||||
_searchCancel = new();
|
||||
|
||||
await Task.Delay(500, _searchCancel.Token);
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload() {
|
||||
_loading = true;
|
||||
if (!string.IsNullOrEmpty(_searchTerm)) {
|
||||
(var query, _totalPages) = await _manager!.Search(_searchTerm, 0, PerPage);
|
||||
_currentlyDisplayedModels = query.ToArray();
|
||||
}
|
||||
else {
|
||||
await OnInitializedAsync();
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task ChangePage(int page) {
|
||||
if (page < 0 || page > _totalPages - 1) return;
|
||||
_currentPage = page;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task DeleteEntry(object element) {
|
||||
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
|
||||
var result = await dialog.Result;
|
||||
if (result.Cancelled) return;
|
||||
|
||||
await _manager!.DeleteItem(element);
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task CreateOrEdit(object? element) {
|
||||
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
|
||||
TrapFocus = false
|
||||
});
|
||||
var result = await panel.Result;
|
||||
var data = result.Data as EditorDialogData;
|
||||
|
||||
if (result.Cancelled) {
|
||||
if (data?.CurrentObject is not null)
|
||||
await _manager!.RevertChanges(data.CurrentObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element is null)
|
||||
await _manager!.AddItem(data!.CurrentObject!);
|
||||
else
|
||||
await _manager!.EditItem(data!.CurrentObject!);
|
||||
|
||||
await Reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hopframe-paginator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hopframe-radio {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
|
||||
}
|
||||
8
src/HopFrame.Web/Models/RelationPickerDialogData.cs
Normal file
8
src/HopFrame.Web/Models/RelationPickerDialogData.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Web.Models;
|
||||
|
||||
public class RelationPickerDialogData(TableConfig sourceTable, object? current) {
|
||||
public object? Object { get; set; } = current;
|
||||
public TableConfig SourceTable { get; init; } = sourceTable;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
|
||||
|
||||
modelBuilder.Entity<Post>()
|
||||
.HasOne<User>()
|
||||
.WithMany();
|
||||
.WithMany()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,8 @@ public class User {
|
||||
public string? Password { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
|
||||
public override string ToString() {
|
||||
return Username;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ builder.Services.AddHopFrame(options => {
|
||||
context.Table<Post>()
|
||||
.Property(p => p.Id)
|
||||
.SetDisplayName("ID");
|
||||
|
||||
context.Table<Post>()
|
||||
.Property(p => p.Author)
|
||||
.IsRelation(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user