diff --git a/HopFrame.slnx b/HopFrame.slnx index c3fee8a..848a532 100644 --- a/HopFrame.slnx +++ b/HopFrame.slnx @@ -4,6 +4,7 @@ + diff --git a/debug/TestApplication/DatabaseContext.cs b/debug/TestApplication/DatabaseContext.cs index 3abdad6..1ecf409 100644 --- a/debug/TestApplication/DatabaseContext.cs +++ b/debug/TestApplication/DatabaseContext.cs @@ -12,6 +12,12 @@ public class DatabaseContext(DbContextOptions options) : DbCont protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + modelBuilder.Entity() + .HasKey(p => p.Id); + + modelBuilder.Entity() + .HasKey(u => u.Id); + modelBuilder.Entity() .HasMany(u => u.Posts) .WithOne(p => p.Sender) diff --git a/debug/TestApplication/Program.cs b/debug/TestApplication/Program.cs index c1c26f8..2d2ead7 100644 --- a/debug/TestApplication/Program.cs +++ b/debug/TestApplication/Program.cs @@ -1,8 +1,9 @@ -using HopFrame.Core; using HopFrame.Core.EFCore; +using HopFrame.Web; using Microsoft.EntityFrameworkCore; using TestApplication; using TestApplication.Components; +using TestApplication.Models; var builder = WebApplication.CreateBuilder(args); @@ -10,13 +11,23 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); -builder.Services.AddEntityFrameworkInMemoryDatabase(); builder.Services.AddDbContext(options => { options.UseInMemoryDatabase("testing"); }); builder.Services.AddHopFrame(config => { config.AddDbContext(); + + config.Table(table => { + table.SetDescription("The user dataset. It contains all information for the users of the application."); + + table.Property(u => u.Password) + .Listable(false); + }); + + config.Table(table => { + table.SetDescription("The posts dataset. It contains all posts sent via the application."); + }); }); var app = builder.Build(); @@ -28,6 +39,27 @@ if (!app.Environment.IsDevelopment()) { app.UseHsts(); } +await using (var scope = app.Services.CreateAsyncScope()) { + var context = scope.ServiceProvider.GetRequiredService(); + + foreach (var _ in Enumerable.Range(1, 100)) { + var firstName = Faker.Name.First(); + var lastName = Faker.Name.Last(); + + context.Users.Add(new() { + Email = $"{firstName}.{lastName}@gmail.com".ToLower(), + Username = $"{firstName}.{lastName}".ToLower(), + FirstName = firstName, + LastName = lastName, + Description = Faker.Lorem.Paragraph(), + Birth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()), + Password = Faker.RandomNumber.Next(100000L, 99999999999999999L).ToString() + }); + } + + await context.SaveChangesAsync(); +} + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseHttpsRedirection(); @@ -35,6 +67,7 @@ app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddHopFrame(); app.Run(); \ No newline at end of file diff --git a/debug/TestApplication/TestApplication.csproj b/debug/TestApplication/TestApplication.csproj index 7ee6932..ad0064a 100644 --- a/debug/TestApplication/TestApplication.csproj +++ b/debug/TestApplication/TestApplication.csproj @@ -9,9 +9,11 @@ + + diff --git a/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs b/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs index a5378a4..13038fa 100644 --- a/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs +++ b/src/HopFrame.Core/Configurators/HopFrameConfigurator.cs @@ -50,4 +50,21 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv configurator?.Invoke(new TableConfigurator(config)); return this; } + + /// + /// Loads the configurator for an existing table in the configuration + /// + /// The configurator for the table + /// The model of the table + /// Is thrown when no table with the requested type was found + public TableConfigurator Table(Action>? configurator = null) where TModel : class { + var table = Config.Tables.FirstOrDefault(t => t.TableType == typeof(TModel)); + + if (table is null) + throw new ArgumentException($"Table '{typeof(TModel).Name}' not found"); + + var modeller = new TableConfigurator(table); + configurator?.Invoke(modeller); + return modeller; + } } \ No newline at end of file diff --git a/src/HopFrame.Core/EFCore/EfCoreRepository.cs b/src/HopFrame.Core/EFCore/EfCoreRepository.cs index b386b34..3e9f841 100644 --- a/src/HopFrame.Core/EFCore/EfCoreRepository.cs +++ b/src/HopFrame.Core/EFCore/EfCoreRepository.cs @@ -3,42 +3,36 @@ using Microsoft.EntityFrameworkCore; namespace HopFrame.Core.EFCore; -/// -/// The generic repository that handles data source communication for managed tables -/// -/// The model that is managed by the repo -/// The underlying context that handles database communication -public class EfCoreRepository(TContext context) : HopFrameRepository where TModel : class where TContext : DbContext { +internal class EfCoreRepository(TContext context) : HopFrameRepository where TModel : class where TContext : DbContext { - /// - public override Task> LoadPageAsync(int page, int perPage, CancellationToken ct = default) { - throw new NotImplementedException(); //TODO: Implement loading functionality + public override async Task> LoadPageAsync(int page, int perPage, CancellationToken ct = default) { + var set = context.Set(); + return await set + .AsNoTracking() + .Skip(page * perPage) + .Take(perPage) + .ToArrayAsync(ct); //TODO: Implement FK loading } - /// public override async Task CountAsync(CancellationToken ct = default) { - var table = context.Set(); - return await table.CountAsync(ct); + var set = context.Set(); + return await set.CountAsync(ct); } - /// public override Task> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) { - throw new NotImplementedException(); //TODO: Implement search functionality + return LoadPageAsync(page, perPage, ct); //TODO: Implement search functionality } - /// public override async Task CreateAsync(TModel entry, CancellationToken ct = default) { await context.AddAsync(entry, ct); await context.SaveChangesAsync(ct); } - /// public override async Task UpdateAsync(TModel entry, CancellationToken ct = default) { context.Update(entry); await context.SaveChangesAsync(ct); } - /// public override async Task DeleteAsync(TModel entry, CancellationToken ct = default) { context.Remove(entry); await context.SaveChangesAsync(ct); diff --git a/src/HopFrame.Core/Helpers/ConfigurationHelper.cs b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs index 5823bf9..13c2487 100644 --- a/src/HopFrame.Core/Helpers/ConfigurationHelper.cs +++ b/src/HopFrame.Core/Helpers/ConfigurationHelper.cs @@ -19,8 +19,8 @@ internal static class ConfigurationHelper { RepositoryType = repositoryType, TableType = modelType, Identifier = identifier, - Route = modelType.Name.ToLower(), - DisplayName = modelType.Name, + Route = modelType.Name.ToLower() + 's', + DisplayName = modelType.Name + 's', OrderIndex = global.Tables.Count }; diff --git a/src/HopFrame.Core/ServiceCollectionExtensions.cs b/src/HopFrame.Core/ServiceCollectionExtensions.cs index fe03b3e..526674f 100644 --- a/src/HopFrame.Core/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Core/ServiceCollectionExtensions.cs @@ -10,13 +10,15 @@ namespace HopFrame.Core; public static class ServiceCollectionExtensions { /// Configures the library using the provided configurator - public static void AddHopFrame(this IServiceCollection services, Action configurator) { + public static IServiceCollection AddHopFrameServices(this IServiceCollection services, Action configurator) { var config = new HopFrameConfig(); services.AddSingleton(config); services.AddTransient(); + services.AddTransient(); configurator.Invoke(new HopFrameConfigurator(config, services)); + return services; } } \ No newline at end of file diff --git a/src/HopFrame.Core/Services/IEntityAccessor.cs b/src/HopFrame.Core/Services/IEntityAccessor.cs new file mode 100644 index 0000000..d97aa99 --- /dev/null +++ b/src/HopFrame.Core/Services/IEntityAccessor.cs @@ -0,0 +1,23 @@ +using HopFrame.Core.Configuration; + +namespace HopFrame.Core.Services; + +/// A service used to modify the actual properties of a model +public interface IEntityAccessor { + + /// + /// Returns the formatted content of the property, ready to be displayed + /// + /// The model to pull the property from + /// The property that shall be extracted + public Task GetValue(object model, PropertyConfig property); + + /// + /// Properly formats and sets the new value of the property + /// + /// The model to save the property to + /// The property that shall be modified + /// The new value of the property + public Task SetValue(object model, PropertyConfig property, object value); + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs b/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs new file mode 100644 index 0000000..a532cd6 --- /dev/null +++ b/src/HopFrame.Core/Services/Implementation/EntityAccessor.cs @@ -0,0 +1,25 @@ +using HopFrame.Core.Configuration; + +namespace HopFrame.Core.Services.Implementation; + +internal class EntityAccessor : IEntityAccessor { + + public async Task GetValue(object model, PropertyConfig property) { + var prop = model.GetType().GetProperty(property.Identifier); + + if (prop is null) + return null; + + return prop.GetValue(model)?.ToString(); + } + + public async Task SetValue(object model, PropertyConfig property, object value) { + var prop = model.GetType().GetProperty(property.Identifier); + + if (prop is null) + return; + + prop.SetValue(model, Convert.ChangeType(value, property.Type)); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/App.razor b/src/HopFrame.Web/Components/App.razor new file mode 100644 index 0000000..726edd5 --- /dev/null +++ b/src/HopFrame.Web/Components/App.razor @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/HopFrame.Web/Components/CancellableComponent.cs b/src/HopFrame.Web/Components/CancellableComponent.cs new file mode 100644 index 0000000..9b25d1e --- /dev/null +++ b/src/HopFrame.Web/Components/CancellableComponent.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Components; + +namespace HopFrame.Web.Components; + +public class CancellableComponent : ComponentBase, IDisposable { + protected CancellationTokenSource TokenSource { get; } = new(); + + public void Dispose() { + TokenSource.Dispose(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Components/Card.razor b/src/HopFrame.Web/Components/Components/Card.razor new file mode 100644 index 0000000..7dc3a49 --- /dev/null +++ b/src/HopFrame.Web/Components/Components/Card.razor @@ -0,0 +1,33 @@ + + + + + + + @Title + + + + @Description + + + Open + + + +@code { + [Parameter] + public required string Title { get; set; } + + [Parameter] + public string? Subtitle { get; set; } + + [Parameter] + public string? Description { get; set; } + + [Parameter] + public required string Href { get; set; } + + [Parameter] + public required string Icon { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Components/Sidebar.razor b/src/HopFrame.Web/Components/Components/Sidebar.razor new file mode 100644 index 0000000..2d398e7 --- /dev/null +++ b/src/HopFrame.Web/Components/Components/Sidebar.razor @@ -0,0 +1,40 @@ +@using HopFrame.Core.Configuration + + + + Dashboard + +
+ + @foreach (var route in Routes) { + @route.Name + } +
+
+ +@inject HopFrameConfig Config + +@code { + + private readonly struct RouteDefinition { + public required string Route { get; init; } + public required string Icon { get; init; } + public required string Name { get; init; } + } + + private RouteDefinition[] Routes { get; set; } = null!; + + protected override void OnInitialized() { + base.OnInitialized(); + + Routes = Config.Tables + .OrderBy(t => t.OrderIndex) + .Select(table => new RouteDefinition { + Route = "admin/" + table.Route, + Icon = Icons.Material.Filled.TableChart, + Name = table.DisplayName + }) + .ToArray(); + } + +} diff --git a/src/HopFrame.Web/Components/Components/Table.razor b/src/HopFrame.Web/Components/Components/Table.razor new file mode 100644 index 0000000..de5d57a --- /dev/null +++ b/src/HopFrame.Web/Components/Components/Table.razor @@ -0,0 +1,59 @@ +@rendermode InteractiveServer + + + + @Config.DisplayName + + + + Add + + + + @foreach (var prop in OrderedProperties) { + + @if (prop.Sortable) { + @prop.DisplayName + } + else { + @prop.DisplayName + } + + } + + Actions + + + @foreach (var prop in OrderedProperties) { + @context[prop.Identifier] + } + + + + + + + + + + + + \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Components/Table.razor.cs b/src/HopFrame.Web/Components/Components/Table.razor.cs new file mode 100644 index 0000000..3eedd99 --- /dev/null +++ b/src/HopFrame.Web/Components/Components/Table.razor.cs @@ -0,0 +1,68 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Repositories; +using HopFrame.Core.Services; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace HopFrame.Web.Components.Components; + +public partial class Table(IEntityAccessor accessor, IConfigAccessor configAccessor) : ComponentBase { + + [Parameter] + public required TableConfig Config { get; set; } + + private IHopFrameRepository Repository { get; set; } = null!; + + private PropertyConfig[] OrderedProperties { get; set; } = null!; + + private MudTable> Manager { get; set; } = null!; + + protected override void OnInitialized() { + base.OnInitialized(); + + Repository = configAccessor.LoadRepository(Config); + + OrderedProperties = Config.Properties + .Where(p => p.Listable) + .OrderBy(p => p.OrderIndex) + .ToArray(); + } + + private async Task>> PrepareData(object[] entries) { + var list = new List>(); + + foreach (var entry in entries) { + var taskDict = new Dictionary>(); + foreach (var prop in OrderedProperties) { + taskDict.Add(prop.Identifier, accessor.GetValue(entry, prop)); + } + + await Task.WhenAll(taskDict.Values); + + var dict = new Dictionary(); + foreach (var prop in taskDict) { + dict.Add(prop.Key, prop.Value.Result ?? string.Empty); + } + + list.Add(dict); + } + + return list; + } + + private async Task>> Reload(TableState state, CancellationToken ct) { + var entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct); + var data = await PrepareData(entries.Cast().ToArray()); + var total = await Repository.CountAsync(ct); + + return new TableData> { + TotalItems = total, + Items = data + }; + } + + private async Task OnSearch(string searchText) { + Console.WriteLine(searchText); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Components/Topbar.razor b/src/HopFrame.Web/Components/Components/Topbar.razor new file mode 100644 index 0000000..5ec2c7a --- /dev/null +++ b/src/HopFrame.Web/Components/Components/Topbar.razor @@ -0,0 +1,3 @@ + + HopFrame + \ No newline at end of file diff --git a/src/HopFrame.Web/Components/HopFrameLayout.razor b/src/HopFrame.Web/Components/HopFrameLayout.razor new file mode 100644 index 0000000..3af4b6b --- /dev/null +++ b/src/HopFrame.Web/Components/HopFrameLayout.razor @@ -0,0 +1,18 @@ +@using HopFrame.Web.Components.Components +@inherits LayoutComponentBase + + + + + + + + + + + + + + @Body + + diff --git a/src/HopFrame.Web/Components/Pages/HomePage.razor b/src/HopFrame.Web/Components/Pages/HomePage.razor new file mode 100644 index 0000000..37feb2f --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/HomePage.razor @@ -0,0 +1,51 @@ +@page "/admin" +@using HopFrame.Core.Configuration +@using HopFrame.Web.Components.Components +@layout HopFrameLayout + +HopFrame + +
+ Pages + +
+ + + @foreach (var route in Routes) { + + } + +
+ +@inject HopFrameConfig Config + +@code { + + private readonly struct RouteDefinition { + public required string Route { get; init; } + public required string Icon { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + } + + private RouteDefinition[] Routes { get; set; } = null!; + + protected override void OnInitialized() { + base.OnInitialized(); + + Routes = Config.Tables + .OrderBy(t => t.OrderIndex) + .Select(table => new RouteDefinition { + Route = "admin/" + table.Route, + Icon = Icons.Material.Filled.TableChart, + Name = table.DisplayName, + Description = table.Description, + }) + .ToArray(); + } + +} diff --git a/src/HopFrame.Web/Components/Pages/TablePage.razor b/src/HopFrame.Web/Components/Pages/TablePage.razor new file mode 100644 index 0000000..1c5201f --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/TablePage.razor @@ -0,0 +1,13 @@ +@page "/admin/{TableRoute}" +@using HopFrame.Web.Components.Components +@inherits CancellableComponent +@rendermode InteractiveServer +@layout HopFrameLayout + + + + + +HopFrame - @Table.DisplayName + +
diff --git a/src/HopFrame.Web/Components/Pages/TablePage.razor.cs b/src/HopFrame.Web/Components/Pages/TablePage.razor.cs new file mode 100644 index 0000000..1e2bb62 --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/TablePage.razor.cs @@ -0,0 +1,27 @@ +using HopFrame.Core.Configuration; +using HopFrame.Core.Services; +using Microsoft.AspNetCore.Components; + +namespace HopFrame.Web.Components.Pages; + +public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : CancellableComponent { + private const int PerPage = 25; + + [Parameter] + public string TableRoute { get; set; } = null!; + + public TableConfig Table { get; set; } = null!; + + protected override void OnInitialized() { + base.OnInitialized(); + + var table = accessor.GetTableByRoute(TableRoute); + + if (table is null) { + navigator.NavigateTo("/admin", true); + return; + } + + Table = table; + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj new file mode 100644 index 0000000..d78e22b --- /dev/null +++ b/src/HopFrame.Web/HopFrame.Web.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + true + MIT + HopFrame.Web + + + + + + + + + + + + + + + + + + + + diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..fbf6468 --- /dev/null +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using HopFrame.Core; +using HopFrame.Core.Configurators; +using HopFrame.Web.Components; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; + +namespace HopFrame.Web; + +/// An extension class to provide access to the setup of the library +public static class ServiceCollectionExtensions { + + /// Configures the library using the provided configurator + public static IServiceCollection AddHopFrame(this IServiceCollection services, Action configurator) { + services.AddHopFrameServices(configurator); + + services.AddMudServices(); + return services; + } + + /// + /// Adds the HopFrame admin ui endpoints + /// + public static RazorComponentsEndpointConventionBuilder AddHopFrame(this RazorComponentsEndpointConventionBuilder builder) { + builder + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly); + + return builder; + } + + /// + /// Adds the HopFrame admin ui endpoints + /// + public static WebApplication MapHopFrame(this WebApplication app) { + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + return app; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/_Imports.razor b/src/HopFrame.Web/_Imports.razor new file mode 100644 index 0000000..deb12ed --- /dev/null +++ b/src/HopFrame.Web/_Imports.razor @@ -0,0 +1,8 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using MudBlazor \ No newline at end of file diff --git a/src/HopFrame.Web/wwwroot/hopframe.css b/src/HopFrame.Web/wwwroot/hopframe.css new file mode 100644 index 0000000..068c289 --- /dev/null +++ b/src/HopFrame.Web/wwwroot/hopframe.css @@ -0,0 +1,3 @@ +.mud-card-header-avatar { + display: flex; +} \ No newline at end of file diff --git a/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs b/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs index 551eb11..00d54d0 100644 --- a/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs +++ b/tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs @@ -87,8 +87,8 @@ public class ConfigurationHelperTests { Assert.Equal(typeof(string), config.RepositoryType); Assert.Equal(typeof(TestModel), config.TableType); - Assert.Equal("testmodel", config.Route); - Assert.Equal("TestModel", config.DisplayName); + Assert.Equal("testmodels", config.Route); + Assert.Equal("TestModels", config.DisplayName); Assert.Equal(0, config.OrderIndex); } diff --git a/tests/HopFrame.Tests.Core/TestModel.cs b/tests/HopFrame.Tests.Core/TestModel.cs index c7239f6..74663d3 100644 --- a/tests/HopFrame.Tests.Core/TestModel.cs +++ b/tests/HopFrame.Tests.Core/TestModel.cs @@ -2,7 +2,7 @@ public class TestModel { public int Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = null!; public int Method() => 42;