Started working on frontend
All checks were successful
HopFrame CI / build (push) Successful in 44s
HopFrame CI / test (push) Successful in 52s

This commit is contained in:
2026-02-25 16:33:46 +01:00
parent d2082ef33c
commit ff2634ff41
27 changed files with 558 additions and 26 deletions

View File

@@ -4,6 +4,7 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/HopFrame.Core/HopFrame.Core.csproj" />
<Project Path="src/HopFrame.Web/HopFrame.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj" />

View File

@@ -12,6 +12,12 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Post>()
.HasKey(p => p.Id);
modelBuilder.Entity<User>()
.HasKey(u => u.Id);
modelBuilder.Entity<User>()
.HasMany(u => u.Posts)
.WithOne(p => p.Sender)

View File

@@ -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<DatabaseContext>(options => {
options.UseInMemoryDatabase("testing");
});
builder.Services.AddHopFrame(config => {
config.AddDbContext<DatabaseContext>();
config.Table<User>(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<Post>(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<DatabaseContext>();
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<App>()
.AddInteractiveServerRenderMode();
.AddInteractiveServerRenderMode()
.AddHopFrame();
app.Run();

View File

@@ -9,9 +9,11 @@
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
<ProjectReference Include="..\..\src\HopFrame.Web\HopFrame.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Faker.Net" Version="2.0.163" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.3" />
</ItemGroup>

View File

@@ -50,4 +50,21 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
configurator?.Invoke(new TableConfigurator<TModel>(config));
return this;
}
/// <summary>
/// Loads the configurator for an existing table in the configuration
/// </summary>
/// <param name="configurator">The configurator for the table</param>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <exception cref="ArgumentException">Is thrown when no table with the requested type was found</exception>
public TableConfigurator<TModel> Table<TModel>(Action<TableConfigurator<TModel>>? 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<TModel>(table);
configurator?.Invoke(modeller);
return modeller;
}
}

View File

@@ -3,42 +3,36 @@ using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.EFCore;
/// <summary>
/// The generic repository that handles data source communication for managed tables
/// </summary>
/// <typeparam name="TModel">The model that is managed by the repo</typeparam>
/// <typeparam name="TContext">The underlying context that handles database communication</typeparam>
public class EfCoreRepository<TModel, TContext>(TContext context) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
internal class EfCoreRepository<TModel, TContext>(TContext context) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
/// <inheritdoc/>
public override Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
throw new NotImplementedException(); //TODO: Implement loading functionality
public override async Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
var set = context.Set<TModel>();
return await set
.AsNoTracking()
.Skip(page * perPage)
.Take(perPage)
.ToArrayAsync(ct); //TODO: Implement FK loading
}
/// <inheritdoc/>
public override async Task<int> CountAsync(CancellationToken ct = default) {
var table = context.Set<TModel>();
return await table.CountAsync(ct);
var set = context.Set<TModel>();
return await set.CountAsync(ct);
}
/// <inheritdoc/>
public override Task<IEnumerable<TModel>> 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
}
/// <inheritdoc/>
public override async Task CreateAsync(TModel entry, CancellationToken ct = default) {
await context.AddAsync(entry, ct);
await context.SaveChangesAsync(ct);
}
/// <inheritdoc/>
public override async Task UpdateAsync(TModel entry, CancellationToken ct = default) {
context.Update(entry);
await context.SaveChangesAsync(ct);
}
/// <inheritdoc/>
public override async Task DeleteAsync(TModel entry, CancellationToken ct = default) {
context.Remove(entry);
await context.SaveChangesAsync(ct);

View File

@@ -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
};

View File

@@ -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<HopFrameConfigurator> configurator) {
public static IServiceCollection AddHopFrameServices(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
var config = new HopFrameConfig();
services.AddSingleton(config);
services.AddTransient<IConfigAccessor, ConfigAccessor>();
services.AddTransient<IEntityAccessor, EntityAccessor>();
configurator.Invoke(new HopFrameConfigurator(config, services));
return services;
}
}

View File

@@ -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 {
/// <summary>
/// Returns the formatted content of the property, ready to be displayed
/// </summary>
/// <param name="model">The model to pull the property from</param>
/// <param name="property">The property that shall be extracted</param>
public Task<string?> GetValue(object model, PropertyConfig property);
/// <summary>
/// Properly formats and sets the new value of the property
/// </summary>
/// <param name="model">The model to save the property to</param>
/// <param name="property">The property that shall be modified</param>
/// <param name="value">The new value of the property</param>
public Task SetValue(object model, PropertyConfig property, object value);
}

View File

@@ -0,0 +1,25 @@
using HopFrame.Core.Configuration;
namespace HopFrame.Core.Services.Implementation;
internal class EntityAccessor : IEntityAccessor {
public async Task<string?> 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));
}
}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Router AppAssembly="typeof(ServiceCollectionExtensions).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData"/>
</Found>
</Router>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -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();
}
}

View File

@@ -0,0 +1,33 @@
<MudCard Style="width: 350px; height: 200px">
<MudCardHeader>
<CardHeaderAvatar>
<MudIcon Icon="@Icon" Style="margin: auto" />
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.h6">@Title</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>@Description</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Href="@Href">Open</MudButton>
</MudCardActions>
</MudCard>
@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; }
}

View File

@@ -0,0 +1,40 @@
@using HopFrame.Core.Configuration
<MudDrawer Open="true" Fixed="true" ClipMode="DrawerClipMode.Docked" Width="200px">
<MudNavMenu>
<MudNavLink Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.SpaceDashboard" Href="admin">Dashboard</MudNavLink>
<br>
@foreach (var route in Routes) {
<MudNavLink Match="NavLinkMatch.All" Icon="@route.Icon" Href="@route.Route">@route.Name</MudNavLink>
}
</MudNavMenu>
</MudDrawer>
@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();
}
}

View File

@@ -0,0 +1,59 @@
@rendermode InteractiveServer
<MudTable ServerData="Reload"
@ref="Manager"
Hover="true"
Breakpoint="Breakpoint.Sm"
LoadingProgressColor="Color.Info"
HorizontalScrollbar="true"
FixedHeader="true"
FixedFooter="true"
Height="calc(100vh - 164px)">
<ToolBarContent>
<MudText Typo="Typo.h6">@Config.DisplayName</MudText>
<MudSpacer />
<MudStack Row="true" Spacing="2" Style="min-width: 500px">
<MudTextField
T="string"
Placeholder="Search"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
Class="mt-0"
FullWidth="true"
Clearable="true"
DebounceInterval="200"
OnDebounceIntervalElapsed="@(s => OnSearch(s))"/>
<MudButton EndIcon="@Icons.Material.Filled.Add" Style="margin-right: 0.5rem">Add</MudButton>
</MudStack>
</ToolBarContent>
<HeaderContent>
@foreach (var prop in OrderedProperties) {
<MudTh>
@if (prop.Sortable) {
<MudTableSortLabel SortLabel="@prop.Identifier" T="string">@prop.DisplayName</MudTableSortLabel>
}
else {
@prop.DisplayName
}
</MudTh>
}
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
@foreach (var prop in OrderedProperties) {
<MudTd DataLabel="@prop.DisplayName" Style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 500px">@context[prop.Identifier]</MudTd>
}
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" />
</MudStack>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>

View File

@@ -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<Dictionary<string, string>> 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<List<Dictionary<string, string>>> PrepareData(object[] entries) {
var list = new List<Dictionary<string, string>>();
foreach (var entry in entries) {
var taskDict = new Dictionary<string, Task<string?>>();
foreach (var prop in OrderedProperties) {
taskDict.Add(prop.Identifier, accessor.GetValue(entry, prop));
}
await Task.WhenAll(taskDict.Values);
var dict = new Dictionary<string, string>();
foreach (var prop in taskDict) {
dict.Add(prop.Key, prop.Value.Result ?? string.Empty);
}
list.Add(dict);
}
return list;
}
private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) {
var entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct);
var data = await PrepareData(entries.Cast<object>().ToArray());
var total = await Repository.CountAsync(ct);
return new TableData<Dictionary<string, string>> {
TotalItems = total,
Items = data
};
}
private async Task OnSearch(string searchText) {
Console.WriteLine(searchText);
}
}

View File

@@ -0,0 +1,3 @@
<MudAppBar Dense="true" Elevation="0">
HopFrame
</MudAppBar>

View File

@@ -0,0 +1,18 @@
@using HopFrame.Web.Components.Components
@inherits LayoutComponentBase
<link rel="stylesheet" href="@Assets["_content/HopFrame.Web/HopFrame.Web.bundle.scp.css"]"/>
<link rel="stylesheet" href="@Assets["_content/HopFrame.Web/hopframe.css"]"/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]" defer></script>
<MudThemeProvider IsDarkMode="true" />
<MudLayout>
<Topbar />
<Sidebar />
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>

View File

@@ -0,0 +1,51 @@
@page "/admin"
@using HopFrame.Core.Configuration
@using HopFrame.Web.Components.Components
@layout HopFrameLayout
<PageTitle>HopFrame</PageTitle>
<section style="padding: 1.5rem">
<MudText Typo="Typo.h5">Pages</MudText>
<br>
<MudStack Wrap="Wrap.Wrap" Row="true">
@foreach (var route in Routes) {
<Card
Title="@route.Name"
Href="@route.Route"
Icon="@route.Icon"
Description="@route.Description"/>
}
</MudStack>
</section>
@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();
}
}

View File

@@ -0,0 +1,13 @@
@page "/admin/{TableRoute}"
@using HopFrame.Web.Components.Components
@inherits CancellableComponent
@rendermode InteractiveServer
@layout HopFrameLayout
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider/>
<PageTitle>HopFrame - @Table.DisplayName</PageTitle>
<Table Config="Table"></Table>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Web</PackageId>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser"/>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0"/>
<PackageReference Include="MudBlazor" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<HopFrameConfigurator> configurator) {
services.AddHopFrameServices(configurator);
services.AddMudServices();
return services;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static RazorComponentsEndpointConventionBuilder AddHopFrame(this RazorComponentsEndpointConventionBuilder builder) {
builder
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly);
return builder;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static WebApplication MapHopFrame(this WebApplication app) {
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
return app;
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
.mud-card-header-avatar {
display: flex;
}

View File

@@ -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);
}

View File

@@ -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;