Resolve "Exporters" #71

Merged
leon.hoppe merged 3 commits from feature/exporters into dev 2025-02-28 12:22:53 +01:00
9 changed files with 220 additions and 19 deletions
Showing only changes of commit 6c42008a28 - Show all commits

View File

@@ -12,17 +12,15 @@
</component>
<component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.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/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/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/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/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/HopFramePlugin.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/HopFramePlugin.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$/tests/HopFrame.Tests.Core/Config/TableConfiguratorTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Config/TableConfiguratorTests.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/DisplayPropertyTests.cs" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -42,7 +40,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="dev" />
<entry key="$PROJECT_DIR$" value="feature/virtual-properties" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -75,6 +73,7 @@
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/24dd1164ba47541cb1d3eb011e638e16953dbea3ae3f4dc208c3bbf3e96298a/ServiceCollectionServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<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/2a4d2ce4c06ab596b3676c5cf06066b4391ec7dd93cdf8f0334b69dc1a9de/TextReader.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed/ToCollection.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/4c41a7d338749915157d56585365d1693fbad6be8231d3d583b1cf10d16896d9/FluentIcon.razor.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/4ee221fd7e91e9a4c14ff82aae2ee938edecde35a934133e991aba56aa9499/Icon.cs" root0="FORCE_HIGHLIGHTING" />
@@ -94,6 +93,7 @@
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bfff78ecaa39c818519fc918bb2d4bbdca6ad93d7170f5cf325f67ccd0b97d43/BooleanAsserts.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d0165cb640e16fb3b8fe6932c042fc2917cd7f2770ff123cf7b9d11b5bfc6/Task.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d04a416cac8afac0341a8be0e859b230f2eae64924298eef48c317ba35916/RenderTreeBuilder.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126/Stream.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d39923abb31e6a6e7a9e8173e217da584c54925ce63e568126a2b89b9ab/DefaultRazorComponentsServiceOptionsConfiguration.cs" root0="FORCE_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/dac3553c90d47a746e7e7f02faecb1a5e581090/Components_AppBar_FluentAppBarItem_razor.g.cs" root0="FORCE_HIGHLIGHTING" />
@@ -127,14 +127,14 @@
"keyToString": {
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Debug",
".NET Project.HopFrame.Testing.executor": "Run",
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
"git-widget-placeholder": "!32 on feature/virtual-properties",
"git-widget-placeholder": "!33 on feature/exporters",
"list.type.of.created.stylesheet": "CSS",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -254,7 +254,8 @@
<workItem from="1739352479748" duration="3047000" />
<workItem from="1739369355001" duration="1751000" />
<workItem from="1739461452173" duration="5533000" />
<workItem from="1739550750776" duration="3388000" />
<workItem from="1739550750776" duration="3613000" />
<workItem from="1739617785048" duration="5712000" />
</task>
<task id="LOCAL-00001" summary="Added basic configuration">
<option name="closed" value="true" />
@@ -584,7 +585,15 @@
<option name="project" value="LOCAL" />
<updated>1738775556256</updated>
</task>
<option name="localTasksCounter" value="42" />
<task id="LOCAL-00042" summary="Added fully virtual properties">
<option name="closed" value="true" />
<created>1739554261551</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1739554261551</updated>
</task>
<option name="localTasksCounter" value="43" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -635,7 +644,6 @@
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Created tests for the core module" />
<MESSAGE value="Added more tests" />
<MESSAGE value="Added web module tests" />
<MESSAGE value="Tested login functionality" />
@@ -660,6 +668,7 @@
<MESSAGE value="Added plugin buttons" />
<MESSAGE value="Added default button removal feature" />
<MESSAGE value="Added custom search functionality" />
<option name="LAST_COMMIT_MESSAGE" value="Added custom search functionality" />
<MESSAGE value="Added fully virtual properties" />
<option name="LAST_COMMIT_MESSAGE" value="Added fully virtual properties" />
</component>
</project>

View File

@@ -10,6 +10,7 @@ public interface ITableManager {
public Task DeleteItem(object item);
public Task EditItem(object item);
public Task AddItem(object item);
public Task AddAll(IEnumerable<object> items);
public Task RevertChanges(object item);
public Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null);

View File

@@ -49,6 +49,12 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
await context.SaveChangesAsync();
}
public async Task AddAll(IEnumerable<object> items) {
var table = context.Set<TModel>();
await table.AddRangeAsync(items.Cast<TModel>());
await context.SaveChangesAsync();
}
public async Task RevertChanges(object item) {
var entry = context.Entry((TModel)item);
await entry.ReloadAsync();

View File

@@ -412,7 +412,7 @@
_tokenSource.Dispose();
}
private enum InputType {
public enum InputType {
Number,
Switch,
Date,

View File

@@ -135,8 +135,26 @@
}
removeBg();
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? '';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}
window.triggerClick = (elt) => elt.click();
</script>
<FluentToastProvider MaxToastCount="10" />
<InputFile style="display: none" @ref="FileInputElement" OnChange="OnInputFiles"></InputFile>
@inject IContextExplorer Explorer
@inject NavigationManager Navigator
@inject IJSRuntime Js
@@ -254,7 +272,7 @@
await Reload();
}
private async Task Reload() {
public async Task Reload() {
_loading = true;
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this) {
@@ -275,7 +293,7 @@
_loading = false;
}
private async Task ChangePage(int page) {
public async Task ChangePage(int page) {
var eventResult = await PluginOrchestrator.DispatchEvent(new PageChangeEvent(this) {
CurrentPage = _currentPage,
NewPage = page,
@@ -383,4 +401,21 @@
return display;
}
public InputFile? FileInputElement;
public Func<IEnumerable<IBrowserFile>, Task>? OnFileUpload;
private async Task OnInputFiles(InputFileChangeEventArgs e) {
if (OnFileUpload is null) return;
if (e.FileCount == 1) {
await OnFileUpload.Invoke([e.File]);
}
else {
await OnFileUpload.Invoke(e.GetMultipleFiles());
}
}
public void RequestRender() {
StateHasChanged();
}
}

View File

@@ -56,4 +56,9 @@ public static class HopFrameConfiguratorExtensions {
return configurator;
}
public static HopFrameConfigurator AddExporters(this HopFrameConfigurator configurator) {
configurator.AddPlugin<ExporterPlugin>();
return configurator;
}
}

View File

@@ -1,3 +1,34 @@
namespace HopFrame.Web.Plugins;
using HopFrame.Web.Components.Pages;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
public abstract class HopFramePlugin;
namespace HopFrame.Web.Plugins;
public abstract class HopFramePlugin {
/// <summary>
/// Downloads a file using a helper js function
/// </summary>
/// <param name="fileName">The name of the file</param>
/// <param name="data">The content of the file</param>
/// <param name="runtime">The js reference for invoking the function (Injectable)</param>
protected async Task DownloadFile(string fileName, byte[] data, IJSRuntime runtime) {
using var stream = new DotNetStreamReference(new MemoryStream(data));
await runtime.InvokeVoidAsync("downloadFileFromStream", fileName, stream);
}
protected Task<IBrowserFile> UploadFile(HopFrameTablePage targetPage, IJSRuntime runtime) {
var result = new TaskCompletionSource<IBrowserFile>();
targetPage.OnFileUpload = files => {
result.SetResult(files.First());
targetPage.OnFileUpload = null;
return Task.CompletedTask;
};
runtime.InvokeVoidAsync("triggerClick", targetPage.FileInputElement!.Element);
return result.Task;
}
}

View File

@@ -0,0 +1,112 @@
using System.Text;
using HopFrame.Core.Config;
using HopFrame.Core.Services;
using HopFrame.Web.Components.Pages;
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
namespace HopFrame.Web.Plugins.Internal;
internal sealed class ExporterPlugin(IContextExplorer explorer, IJSRuntime runtime, IToastService toasts, IServiceProvider provider) : HopFramePlugin {
public const char Separator = ';';
[EventHandler]
public void OnInit(TableInitializedEvent e) {
e.AddPageButton("Export", () => Export(e.Table), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowUpload());
e.AddPageButton("Import", () => Import(e.Table, e.Sender), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowDownload());
}
private async Task Export(TableConfig table) {
var manager = explorer.GetTableManager(table.PropertyName);
if (manager is null) {
toasts.ShowError("Data could not be exported!");
return;
}
var data = await manager
.LoadPage(0, int.MaxValue)
.ToArrayAsync();
var properties = table.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order).ToArray();
var csv = new StringBuilder(string.Join(Separator, properties.Select(prop => prop.Name)) + '\n');
foreach (var entry in data) {
var row = new List<string>();
foreach (var property in properties) {
var value = await manager.DisplayProperty(entry, property);
row.Add(value);
}
csv.Append(string.Join(Separator, row) + '\n');
}
var result = csv.ToString();
await DownloadFile($"{table.DisplayName}.csv", Encoding.UTF8.GetBytes(result), runtime);
}
private async Task Import(TableConfig table, HopFrameTablePage target) {
var file = await UploadFile(target, runtime);
var stream = file.OpenReadStream();
var reader = new StreamReader(stream);
var properties = table.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order).ToArray();
var data = await reader.ReadToEndAsync();
var rows = data.Split('\n');
reader.Dispose();
await stream.DisposeAsync();
var headerProps = rows.First().Split(Separator);
if (!headerProps.Any(h => properties.Any(prop => prop.Name == h))) {
toasts.ShowError("Table header in csv is not valid!");
return;
}
var elements = new List<object>();
for (int rowIndex = 1; rowIndex < rows.Length; rowIndex++) {
var row = rows[rowIndex];
if (string.IsNullOrWhiteSpace(row)) continue;
var element = Activator.CreateInstance(table.TableType)!;
var rowValues = row.Split(Separator);
for (int i = 0; i < headerProps.Length; i++) {
var property = properties.FirstOrDefault(prop => prop.Name == headerProps[i]);
if (property is null) continue;
if (property.IsEnumerable) continue;
object value = rowValues[i];
if (property.Info.PropertyType == typeof(Guid)) {
var success = Guid.TryParse((string)value, out var guid);
if (success) value = guid;
else toasts.ShowError($"'{value}' is not a valid guid");
}
if (property.Parser is not null) {
value = await property.Parser(value.ToString()!, provider);
}
property.SetValue(element, value, provider);
}
elements.Add(element);
}
var manager = explorer.GetTableManager(table.PropertyName);
if (manager is null) {
toasts.ShowError("Data could not be exported!");
return;
}
await manager.AddAll(elements);
await target.Reload();
}
}

View File

@@ -85,6 +85,8 @@ builder.Services.AddHopFrame(options => {
options.AddCustomView("Counter", "/counter")
.SetDescription("A custom view")
.SetPolicy("counter.view");
options.AddExporters();
});
var app = builder.Build();