From 86ace64618695ba1a39c0aba83c8a885eda1f180 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Fri, 28 Feb 2025 12:15:32 +0100 Subject: [PATCH] Finished converter plugin --- .gitlab-ci.yml | 4 + .idea/.idea.HopFrame/.idea/workspace.xml | 74 ++++++---- .../Services/IContextExplorer.cs | 1 + src/HopFrame.Core/Services/ITableManager.cs | 2 +- .../Implementations/ContextExplorer.cs | 15 ++ .../Services/Implementations/TableManager.cs | 5 + .../Components/Pages/HopFrameTablePage.razor | 3 + .../HopFrameConfiguratorExtensions.cs | 2 +- src/HopFrame.Web/Plugins/HopFramePlugin.cs | 34 ----- .../Plugins/Internal/ExporterPlugin.cs | 129 +++++++++++++++--- .../ServiceCollectionExtensions.cs | 3 + src/HopFrame.Web/Services/IFileService.cs | 11 ++ .../Services/Implementation/FileService.cs | 31 +++++ testing/HopFrame.Testing/Program.cs | 7 +- 14 files changed, 231 insertions(+), 90 deletions(-) delete mode 100644 src/HopFrame.Web/Plugins/HopFramePlugin.cs create mode 100644 src/HopFrame.Web/Services/IFileService.cs create mode 100644 src/HopFrame.Web/Services/Implementation/FileService.cs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c156ac..94bd810 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,10 @@ publish: publish-help: stage: publish-help + image: docker:latest + services: + - name: docker:dind + alias: docker script: - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//') - docker login -u leon.hoppe -p ${CI_REGISTRY_PASSWORD} registry.leon-hoppe.de diff --git a/.idea/.idea.HopFrame/.idea/workspace.xml b/.idea/.idea.HopFrame/.idea/workspace.xml index f99fbf2..26305db 100644 --- a/.idea/.idea.HopFrame/.idea/workspace.xml +++ b/.idea/.idea.HopFrame/.idea/workspace.xml @@ -12,15 +12,17 @@ - + + + - - - + + + - { + "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 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": "!33 on feature/exporters", + "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.pluginManager", + "vue.rearranger.settings.migration": "true" } -}]]> +} @@ -644,7 +660,6 @@ \ No newline at end of file diff --git a/src/HopFrame.Core/Services/IContextExplorer.cs b/src/HopFrame.Core/Services/IContextExplorer.cs index ee306be..7eaabc4 100644 --- a/src/HopFrame.Core/Services/IContextExplorer.cs +++ b/src/HopFrame.Core/Services/IContextExplorer.cs @@ -7,4 +7,5 @@ public interface IContextExplorer { public TableConfig? GetTable(string tableDisplayName); public TableConfig? GetTable(Type tableEntity); public ITableManager? GetTableManager(string tablePropertyName); + public ITableManager? GetTableManager(Type tableType); } \ No newline at end of file diff --git a/src/HopFrame.Core/Services/ITableManager.cs b/src/HopFrame.Core/Services/ITableManager.cs index 9858527..2078d10 100644 --- a/src/HopFrame.Core/Services/ITableManager.cs +++ b/src/HopFrame.Core/Services/ITableManager.cs @@ -11,7 +11,7 @@ public interface ITableManager { public Task EditItem(object item); public Task AddItem(object item); public Task AddAll(IEnumerable items); - public Task RevertChanges(object item); + public Task GetOne(object key); public Task DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null); } \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs index e2ba70c..110ddfc 100644 --- a/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs +++ b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs @@ -55,6 +55,21 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr return null; } + public ITableManager? GetTableManager(Type tableType) { + foreach (var context in config.Contexts) { + var table = context.Tables.FirstOrDefault(table => table.TableType == tableType); + if (table is null) continue; + + var dbContext = provider.GetService(context.ContextType) as DbContext; + if (dbContext is null) return null; + + var type = typeof(TableManager<>).MakeGenericType(table.TableType); + return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; + } + + return null; + } + private void SeedTableData(TableConfig table) { if (table.Seeded) return; var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!; diff --git a/src/HopFrame.Core/Services/Implementations/TableManager.cs b/src/HopFrame.Core/Services/Implementations/TableManager.cs index 5ac63e5..95915fc 100644 --- a/src/HopFrame.Core/Services/Implementations/TableManager.cs +++ b/src/HopFrame.Core/Services/Implementations/TableManager.cs @@ -55,6 +55,11 @@ internal sealed class TableManager(DbContext context, TableConfig config await context.SaveChangesAsync(); } + public async Task GetOne(object key) { + var table = context.Set(); + return await table.FindAsync(key); + } + public async Task RevertChanges(object item) { var entry = context.Entry((TModel)item); await entry.ReloadAsync(); diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor index 74c2c6b..89f784f 100644 --- a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor +++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor @@ -202,7 +202,10 @@ private List _pluginButtons = new(); private DefaultButtonToggles _buttonToggles = new(); + internal static HopFrameTablePage? CurrentInstance { get; private set; } + protected override void OnInitialized() { + CurrentInstance = this; _config ??= Explorer.GetTable(TableDisplayName); if (_config is null || (_config.Ignored && DialogData is null)) { diff --git a/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs b/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs index 1dd362b..9eae99e 100644 --- a/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs +++ b/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs @@ -38,7 +38,7 @@ public static class HopFrameConfiguratorExtensions { /// /// The configurator for the HopFrame config that is being created /// The plugin that should be registered - public static HopFrameConfigurator AddPlugin(this HopFrameConfigurator configurator) where TPlugin : HopFramePlugin { + public static HopFrameConfigurator AddPlugin(this HopFrameConfigurator configurator) where TPlugin : class { PluginOrchestrator.RegisterPlugin(configurator.ServiceCollection, typeof(TPlugin)); var methods = typeof(TPlugin).GetMethods() diff --git a/src/HopFrame.Web/Plugins/HopFramePlugin.cs b/src/HopFrame.Web/Plugins/HopFramePlugin.cs deleted file mode 100644 index 4825b61..0000000 --- a/src/HopFrame.Web/Plugins/HopFramePlugin.cs +++ /dev/null @@ -1,34 +0,0 @@ -using HopFrame.Web.Components.Pages; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.JSInterop; - -namespace HopFrame.Web.Plugins; - -public abstract class HopFramePlugin { - - /// - /// Downloads a file using a helper js function - /// - /// The name of the file - /// The content of the file - /// The js reference for invoking the function (Injectable) - 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 UploadFile(HopFrameTablePage targetPage, IJSRuntime runtime) { - var result = new TaskCompletionSource(); - - targetPage.OnFileUpload = files => { - result.SetResult(files.First()); - targetPage.OnFileUpload = null; - return Task.CompletedTask; - }; - - runtime.InvokeVoidAsync("triggerClick", targetPage.FileInputElement!.Element); - return result.Task; - } - -} diff --git a/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs b/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs index 415ff41..64eb65a 100644 --- a/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs +++ b/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs @@ -1,17 +1,18 @@ -using System.Text; +using System.Collections; +using System.ComponentModel.DataAnnotations; +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 HopFrame.Web.Services; 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 { - +internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService toasts, IFileService files, IServiceProvider provider) { public const char Separator = ';'; [EventHandler] @@ -31,32 +32,30 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IJSRuntime runti .LoadPage(0, int.MaxValue) .ToArrayAsync(); - var properties = table.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order).ToArray(); - + var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray(); - var csv = new StringBuilder(string.Join(Separator, properties.Select(prop => prop.Name)) + '\n'); + var csv = new StringBuilder(string.Join(Separator, properties.Select(prop => prop.Info.Name)) + '\n'); foreach (var entry in data) { var row = new List(); foreach (var property in properties) { - var value = await manager.DisplayProperty(entry, property); - row.Add(value); + row.Add(FormatProperty(property, entry)); } csv.Append(string.Join(Separator, row) + '\n'); } var result = csv.ToString(); - await DownloadFile($"{table.DisplayName}.csv", Encoding.UTF8.GetBytes(result), runtime); + await files.DownloadFile($"{table.DisplayName}.csv", Encoding.UTF8.GetBytes(result)); } private async Task Import(TableConfig table, HopFrameTablePage target) { - var file = await UploadFile(target, runtime); + var file = await files.UploadFile(); var stream = file.OpenReadStream(); var reader = new StreamReader(stream); - var properties = table.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order).ToArray(); + var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray(); var data = await reader.ReadToEndAsync(); var rows = data.Split('\n'); @@ -64,7 +63,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IJSRuntime runti await stream.DisposeAsync(); var headerProps = rows.First().Split(Separator); - if (!headerProps.Any(h => properties.Any(prop => prop.Name == h))) { + if (!headerProps.Any(h => properties.Any(prop => prop.Info.Name == h))) { toasts.ShowError("Table header in csv is not valid!"); return; } @@ -78,22 +77,57 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IJSRuntime runti var rowValues = row.Split(Separator); for (int i = 0; i < headerProps.Length; i++) { - var property = properties.FirstOrDefault(prop => prop.Name == headerProps[i]); + var property = properties.FirstOrDefault(prop => prop.Info.Name == headerProps[i]); if (property is null) continue; - if (property.IsEnumerable) continue; - object value = rowValues[i]; + object? value = rowValues[i]; - if (property.Info.PropertyType == typeof(Guid)) { + if (property.IsEnumerable) { + if (!property.Info.PropertyType.IsGenericType) continue; + + var formattedEnumerable = (string)value; + if (formattedEnumerable == "[]") continue; + var values = formattedEnumerable + .TrimStart('[') + .TrimEnd(']') + .Split(','); + + var addMethod = property.Info.PropertyType.GetMethod("Add"); + if (addMethod is null) continue; + + var tableType = property.Info.PropertyType.GenericTypeArguments[0]; + var relationManager = explorer.GetTableManager(tableType); + var primaryKeyType = GetPrimaryKeyType(tableType); + if (relationManager is null || primaryKeyType is null) continue; + + var enumerable = Activator.CreateInstance(property.Info.PropertyType); + foreach (var key in values) { + var entry = await relationManager.GetOne(ParseString(key, primaryKeyType)); + if (entry is null) continue; + + addMethod.Invoke(enumerable, [entry]); + } + + property.Info.SetValue(element, enumerable); + continue; + } + + if (property.IsRelation) { + var relationManager = explorer.GetTableManager(property.Info.PropertyType); + var relationPrimaryKeyType = GetPrimaryKeyType(property.Info.PropertyType); + if (relationManager is null || relationPrimaryKeyType is null) continue; + value = await relationManager.GetOne(ParseString((string)value, relationPrimaryKeyType)); + } + else 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); + else { + value = ParseString((string)value, property.Info.PropertyType); } - property.SetValue(element, value, provider); + + property.Info.SetValue(element, value); } elements.Add(element); @@ -101,12 +135,63 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IJSRuntime runti var manager = explorer.GetTableManager(table.PropertyName); if (manager is null) { - toasts.ShowError("Data could not be exported!"); + toasts.ShowError("Data could not be imported!"); return; } await manager.AddAll(elements); await target.Reload(); } + + private string FormatProperty(PropertyConfig property, object entity) { + var value = property.Info.GetValue(entity); + + if (value is null) + return string.Empty; + + if (property.IsEnumerable) { + var enumerable = (IEnumerable)value; + return '[' + string.Join(',', enumerable.OfType().Select(o => SelectPrimaryKey(o) ?? o.ToString())) + ']'; + } + + return SelectPrimaryKey(value) ?? value.ToString() ?? string.Empty; + } + + private string? SelectPrimaryKey(object entity) { + return entity + .GetType() + .GetProperties() + .FirstOrDefault(prop => prop + .GetCustomAttributes(true) + .Any(attr => attr is KeyAttribute))? + .GetValue(entity)? + .ToString(); + } + + private Type? GetPrimaryKeyType(Type tableType) { + return tableType + .GetProperties() + .FirstOrDefault(prop => prop + .GetCustomAttributes(true) + .Any(attr => attr is KeyAttribute))? + .PropertyType; + } + + private object? ParseString(string input, Type targetType) { + try { + var parseMethod = targetType + .GetMethods() + .Where(method => method.Name.StartsWith("Parse")) + .FirstOrDefault(method => method.GetParameters().SingleOrDefault()?.ParameterType == typeof(string)); + + if (parseMethod is not null) + return parseMethod.Invoke(null, [input]); + + return Convert.ChangeType(input, targetType); + } + catch (Exception) { + return null; + } + } } \ No newline at end of file diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs index ba4d590..2a68b43 100644 --- a/src/HopFrame.Web/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -5,6 +5,8 @@ using HopFrame.Web.Components; using HopFrame.Web.Components.Pages; using HopFrame.Web.Plugins; using HopFrame.Web.Plugins.Internal; +using HopFrame.Web.Services; +using HopFrame.Web.Services.Implementation; using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.AspNetCore.Builder; @@ -41,6 +43,7 @@ public static class ServiceCollectionExtensions { services.AddFluentUIComponents(fluentUiLibraryConfiguration); services.AddScoped(); + services.AddScoped(); if (addRazorComponents) { services.AddRazorComponents() diff --git a/src/HopFrame.Web/Services/IFileService.cs b/src/HopFrame.Web/Services/IFileService.cs new file mode 100644 index 0000000..e8d4dff --- /dev/null +++ b/src/HopFrame.Web/Services/IFileService.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Components.Forms; + +namespace HopFrame.Web.Services; + +public interface IFileService { + + public Task DownloadFile(string name, byte[] data); + + public Task UploadFile(); + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Services/Implementation/FileService.cs b/src/HopFrame.Web/Services/Implementation/FileService.cs new file mode 100644 index 0000000..4cb7939 --- /dev/null +++ b/src/HopFrame.Web/Services/Implementation/FileService.cs @@ -0,0 +1,31 @@ +using HopFrame.Web.Components.Pages; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.JSInterop; + +namespace HopFrame.Web.Services.Implementation; + +internal sealed class FileService(IJSRuntime runtime) : IFileService { + + public async Task DownloadFile(string name, byte[] data) { + using var stream = new DotNetStreamReference(new MemoryStream(data)); + + await runtime.InvokeVoidAsync("downloadFileFromStream", name, stream); + } + + public Task UploadFile() { + var result = new TaskCompletionSource(); + + if (HopFrameTablePage.CurrentInstance is null) + result.SetException(new InvalidOperationException("No table page visible")); + + HopFrameTablePage.CurrentInstance!.OnFileUpload = files => { + result.SetResult(files.First()); + HopFrameTablePage.CurrentInstance.OnFileUpload = null; + return Task.CompletedTask; + }; + + runtime.InvokeVoidAsync("triggerClick", HopFrameTablePage.CurrentInstance.FileInputElement!.Element); + return result.Task; + } + +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Program.cs b/testing/HopFrame.Testing/Program.cs index d615a68..82aff78 100644 --- a/testing/HopFrame.Testing/Program.cs +++ b/testing/HopFrame.Testing/Program.cs @@ -52,7 +52,8 @@ builder.Services.AddHopFrame(options => { context.Table() .Property(p => p.Author) - .Format((user, _) => $"{user.FirstName} {user.LastName}"); + .Format((user, _) => $"{user.FirstName} {user.LastName}") + .SetValidator((_, _) => []); context.Table() .Property(p => p.Id) @@ -77,9 +78,9 @@ builder.Services.AddHopFrame(options => { return errors; })*/; - context.Table() + /*context.Table() .SetOrderIndex(-1) - .Ignore(true); + .Ignore(true);*/ }); options.AddCustomView("Counter", "/counter")