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 1a0342d..c82bd6e 100644 --- a/.idea/.idea.HopFrame/.idea/workspace.xml +++ b/.idea/.idea.HopFrame/.idea/workspace.xml @@ -13,16 +13,8 @@ - - - - - - - - - - + + @@ -635,9 +662,6 @@ \ No newline at end of file diff --git a/README.md b/README.md index 02d5454..7d7fa4b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Welcome to the **HopFrame**! This project aims to provide a comprehensive and modular framework for easy management of your database. The framework is designed to be highly configurable, ensuring that developers either quickly add the framework for simple data editing or -configure it to their needs to implement it fully in their data management pipeline. +configure it to their needs to implement it fully in their data management pipeline. Read more in the project [docs](https://hopframe.leon-hoppe.de). ## Features diff --git a/docs/Writerside/topics/Plugins.md b/docs/Writerside/topics/Plugins.md index d3c7f14..3d96ba2 100644 --- a/docs/Writerside/topics/Plugins.md +++ b/docs/Writerside/topics/Plugins.md @@ -5,10 +5,10 @@ by using Plugins. They are registered as scoped services so you can use DI like ## Add a plugin -Create a class that extends the `HopFramePlugin` class: +Create a class that represents the plugin: ```C# -public class SearchExtension : HopFramePlugin { +public class SearchExtension { } ``` @@ -60,3 +60,19 @@ public void OnDelete(DeleteEntryEvent e) { cacheHandler.ClearCache(e.Entity); } ``` + +## Useful services + +### IFileService + +If you want to deal with file uploading / downloading, you can use the `IFileService`: + +```C# +public interface IFileService { + + public Task DownloadFile(string name, byte[] data); + + public Task UploadFile(); + +} +``` 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 4610ce0..2078d10 100644 --- a/src/HopFrame.Core/Services/ITableManager.cs +++ b/src/HopFrame.Core/Services/ITableManager.cs @@ -10,7 +10,8 @@ public interface ITableManager { public Task DeleteItem(object item); public Task EditItem(object item); public Task AddItem(object item); - public Task RevertChanges(object item); + public Task AddAll(IEnumerable items); + 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 1315def..95915fc 100644 --- a/src/HopFrame.Core/Services/Implementations/TableManager.cs +++ b/src/HopFrame.Core/Services/Implementations/TableManager.cs @@ -49,6 +49,17 @@ internal sealed class TableManager(DbContext context, TableConfig config await context.SaveChangesAsync(); } + public async Task AddAll(IEnumerable items) { + var table = context.Set(); + await table.AddRangeAsync(items.Cast()); + 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/Dialogs/HopFrameEditor.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor index 1006df0..f3e9a90 100644 --- a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor +++ b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor @@ -412,7 +412,7 @@ _tokenSource.Dispose(); } - private enum InputType { + public enum InputType { Number, Switch, Date, diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor index 22826a9..89f784f 100644 --- a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor +++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor @@ -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(); + + + + @inject IContextExplorer Explorer @inject NavigationManager Navigator @inject IJSRuntime Js @@ -184,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)) { @@ -254,7 +275,7 @@ await Reload(); } - private async Task Reload() { + public async Task Reload() { _loading = true; var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this) { @@ -275,7 +296,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 +404,21 @@ return display; } + + public InputFile? FileInputElement; + public Func, 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(); + } } \ No newline at end of file diff --git a/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs b/src/HopFrame.Web/HopFrameConfiguratorExtensions.cs index 01fb2ef..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() @@ -55,5 +55,10 @@ public static class HopFrameConfiguratorExtensions { return configurator; } + + public static HopFrameConfigurator AddExporters(this HopFrameConfigurator configurator) { + configurator.AddPlugin(); + return configurator; + } } \ No newline at end of file diff --git a/src/HopFrame.Web/Plugins/HopFramePlugin.cs b/src/HopFrame.Web/Plugins/HopFramePlugin.cs deleted file mode 100644 index f61bef9..0000000 --- a/src/HopFrame.Web/Plugins/HopFramePlugin.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace HopFrame.Web.Plugins; - -public abstract class HopFramePlugin; diff --git a/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs b/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs new file mode 100644 index 0000000..8746ad9 --- /dev/null +++ b/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs @@ -0,0 +1,197 @@ +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; + +namespace HopFrame.Web.Plugins.Internal; + +internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService toasts, IFileService files) { + 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.IsVirtualProperty).ToArray(); + + 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) { + row.Add(FormatProperty(property, entry)); + } + + csv.Append(string.Join(Separator, row) + '\n'); + } + + var result = csv.ToString(); + await files.DownloadFile($"{table.DisplayName}.csv", Encoding.UTF8.GetBytes(result)); + } + + private async Task Import(TableConfig table, HopFrameTablePage target) { + var file = await files.UploadFile(); + + var stream = file.OpenReadStream(); + var reader = new StreamReader(stream); + + var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).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.Info.Name == h))) { + toasts.ShowError("Table header in csv is not valid!"); + return; + } + + var elements = new List(); + 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.Info.Name == headerProps[i]); + if (property is null) continue; + + object? value = rowValues[i]; + + 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"); + } + else { + value = ParseString((string)value, property.Info.PropertyType); + } + + property.Info.SetValue(element, value); + } + + elements.Add(element); + } + + var manager = explorer.GetTableManager(table.PropertyName); + if (manager is null) { + 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 06375a5..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,14 +78,16 @@ builder.Services.AddHopFrame(options => { return errors; })*/; - context.Table() + /*context.Table() .SetOrderIndex(-1) - .Ignore(true); + .Ignore(true);*/ }); options.AddCustomView("Counter", "/counter") .SetDescription("A custom view") .SetPolicy("counter.view"); + + options.AddExporters(); }); var app = builder.Build();