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