Compare commits
6 Commits
79ed400185
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e9fbf5e9 | |||
| ff2634ff41 | |||
| d2082ef33c | |||
| 6730d57771 | |||
| e8ac7eb88a | |||
| b2a029d50b |
46
.gitea/workflows/ci.yml
Normal file
46
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: HopFrame CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.x"
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.x"
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build --configuration Release --no-restore
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: dotnet test --configuration Release --no-build --verbosity normal
|
||||||
@@ -1 +1,12 @@
|
|||||||
<Solution />
|
<Solution>
|
||||||
|
<Folder Name="/debug/">
|
||||||
|
<Project Path="debug/TestApplication/TestApplication.csproj" />
|
||||||
|
</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" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
|
|||||||
21
debug/TestApplication/Components/App.razor
Normal file
21
debug/TestApplication/Components/App.razor
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<base href="/"/>
|
||||||
|
<ResourcePreloader/>
|
||||||
|
<link rel="stylesheet" href="@Assets["app.css"]"/>
|
||||||
|
<link rel="stylesheet" href="@Assets["TestApplication.styles.css"]"/>
|
||||||
|
<ImportMap/>
|
||||||
|
<HeadOutlet/>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes/>
|
||||||
|
<ReconnectModal/>
|
||||||
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
9
debug/TestApplication/Components/Layout/MainLayout.razor
Normal file
9
debug/TestApplication/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@Body
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
20
debug/TestApplication/Components/Layout/MainLayout.razor.css
Normal file
20
debug/TestApplication/Components/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#blazor-error-ui {
|
||||||
|
color-scheme: light only;
|
||||||
|
background: lightyellow;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
31
debug/TestApplication/Components/Layout/ReconnectModal.razor
Normal file
31
debug/TestApplication/Components/Layout/ReconnectModal.razor
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||||
|
|
||||||
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
|
<div class="components-reconnect-container">
|
||||||
|
<div class="components-rejoining-animation" aria-hidden="true">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
|
Rejoining the server...
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
|
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-failed-visible">
|
||||||
|
Failed to rejoin.<br/>Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<p class="components-pause-visible">
|
||||||
|
The session has been paused by the server.
|
||||||
|
</p>
|
||||||
|
<button id="components-resume-button" class="components-pause-visible">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<p class="components-resume-failed-visible">
|
||||||
|
Failed to resume the session.<br/>Please reload the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
157
debug/TestApplication/Components/Layout/ReconnectModal.razor.css
Normal file
157
debug/TestApplication/Components/Layout/ReconnectModal.razor.css
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.components-reconnect-first-attempt-visible,
|
||||||
|
.components-reconnect-repeated-attempt-visible,
|
||||||
|
.components-reconnect-failed-visible,
|
||||||
|
.components-pause-visible,
|
||||||
|
.components-resume-failed-visible,
|
||||||
|
.components-rejoining-animation {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||||
|
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||||
|
#components-reconnect-modal.components-reconnect-failed,
|
||||||
|
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#components-reconnect-modal {
|
||||||
|
background-color: white;
|
||||||
|
width: 20rem;
|
||||||
|
margin: 20vh auto;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||||
|
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||||
|
&[open]
|
||||||
|
|
||||||
|
{
|
||||||
|
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-slideUp {
|
||||||
|
0% {
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-reconnect-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button {
|
||||||
|
border: 0;
|
||||||
|
background-color: #6b9ed2;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button:hover {
|
||||||
|
background-color: #3b6ea2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button:active {
|
||||||
|
background-color: #6b9ed2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation div {
|
||||||
|
position: absolute;
|
||||||
|
border: 3px solid #0087ff;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation div:nth-child(2) {
|
||||||
|
animation-delay: -0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-rejoining-animation {
|
||||||
|
0% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
4.9% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
5% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Set up event handlers
|
||||||
|
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||||
|
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||||
|
|
||||||
|
const retryButton = document.getElementById("components-reconnect-button");
|
||||||
|
retryButton.addEventListener("click", retry);
|
||||||
|
|
||||||
|
const resumeButton = document.getElementById("components-resume-button");
|
||||||
|
resumeButton.addEventListener("click", resume);
|
||||||
|
|
||||||
|
function handleReconnectStateChanged(event) {
|
||||||
|
if (event.detail.state === "show") {
|
||||||
|
reconnectModal.showModal();
|
||||||
|
} else if (event.detail.state === "hide") {
|
||||||
|
reconnectModal.close();
|
||||||
|
} else if (event.detail.state === "failed") {
|
||||||
|
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
} else if (event.detail.state === "rejected") {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reconnect will asynchronously return:
|
||||||
|
// - true to mean success
|
||||||
|
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||||
|
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||||
|
const successful = await Blazor.reconnect();
|
||||||
|
if (!successful) {
|
||||||
|
// We have been able to reach the server, but the circuit is no longer available.
|
||||||
|
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||||
|
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||||
|
if (!resumeSuccessful) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
reconnectModal.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// We got an exception, server is currently unavailable
|
||||||
|
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume() {
|
||||||
|
try {
|
||||||
|
const successful = await Blazor.resumeCircuit();
|
||||||
|
if (!successful) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryWhenDocumentBecomesVisible() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
await retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
debug/TestApplication/Components/Pages/Error.razor
Normal file
35
debug/TestApplication/Components/Pages/Error.razor
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@page "/Error"
|
||||||
|
@using System.Diagnostics
|
||||||
|
|
||||||
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
|
||||||
|
@if (ShowRequestId) {
|
||||||
|
<p>
|
||||||
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Development Mode</h3>
|
||||||
|
<p>
|
||||||
|
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||||
|
It can result in displaying sensitive information from exceptions to end users.
|
||||||
|
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||||
|
and restarting the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@code{
|
||||||
|
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private string? RequestId { get; set; }
|
||||||
|
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
|
|
||||||
|
}
|
||||||
7
debug/TestApplication/Components/Pages/Home.razor
Normal file
7
debug/TestApplication/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@page "/"
|
||||||
|
|
||||||
|
<PageTitle>Home</PageTitle>
|
||||||
|
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
|
||||||
|
Welcome to your new app.
|
||||||
5
debug/TestApplication/Components/Pages/NotFound.razor
Normal file
5
debug/TestApplication/Components/Pages/NotFound.razor
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@page "/not-found"
|
||||||
|
@layout MainLayout
|
||||||
|
|
||||||
|
<h3>Not Found</h3>
|
||||||
|
<p>Sorry, the content you are looking for does not exist.</p>
|
||||||
6
debug/TestApplication/Components/Routes.razor
Normal file
6
debug/TestApplication/Components/Routes.razor
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
11
debug/TestApplication/Components/_Imports.razor
Normal file
11
debug/TestApplication/Components/_Imports.razor
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@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 Microsoft.JSInterop
|
||||||
|
@using TestApplication
|
||||||
|
@using TestApplication.Components
|
||||||
|
@using TestApplication.Components.Layout
|
||||||
26
debug/TestApplication/DatabaseContext.cs
Normal file
26
debug/TestApplication/DatabaseContext.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TestApplication.Models;
|
||||||
|
|
||||||
|
namespace TestApplication;
|
||||||
|
|
||||||
|
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
|
||||||
|
|
||||||
|
public DbSet<User> Users { get; set; }
|
||||||
|
|
||||||
|
public DbSet<Post> Posts { get; set; }
|
||||||
|
|
||||||
|
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)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
debug/TestApplication/Models/Post.cs
Normal file
13
debug/TestApplication/Models/Post.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TestApplication.Models;
|
||||||
|
|
||||||
|
public class Post {
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public required User Sender { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(5000)]
|
||||||
|
public required string Message { get; set; }
|
||||||
|
}
|
||||||
32
debug/TestApplication/Models/User.cs
Normal file
32
debug/TestApplication/Models/User.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TestApplication.Models;
|
||||||
|
|
||||||
|
public class User {
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; } = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
public int Index { get; set; }
|
||||||
|
|
||||||
|
[EmailAddress, MaxLength(25)]
|
||||||
|
public required string Email { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(25)]
|
||||||
|
public required string Username { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string Password { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(25)]
|
||||||
|
public required string FirstName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(25)]
|
||||||
|
public required string LastName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(255)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public required DateOnly Birth { get; set; } = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
|
||||||
|
public List<Post> Posts { get; set; } = new();
|
||||||
|
}
|
||||||
88
debug/TestApplication/Program.cs
Normal file
88
debug/TestApplication/Program.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using HopFrame.Core.EFCore;
|
||||||
|
using HopFrame.Web;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TestApplication;
|
||||||
|
using TestApplication.Components;
|
||||||
|
using TestApplication.Models;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
table.SetPreferredProperty(u => u.Username);
|
||||||
|
});
|
||||||
|
|
||||||
|
config.Table<Post>(table => {
|
||||||
|
table.SetDescription("The posts dataset. It contains all posts sent via the application.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (!app.Environment.IsDevelopment()) {
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope()) {
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
|
|
||||||
|
foreach (var i 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(),
|
||||||
|
Index = i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
context.Posts.Add(new() {
|
||||||
|
Message = Faker.Lorem.Paragraph(),
|
||||||
|
Sender = context.Users.First()
|
||||||
|
});
|
||||||
|
|
||||||
|
context.Posts.Add(new() {
|
||||||
|
Message = Faker.Lorem.Paragraph(),
|
||||||
|
Sender = context.Users.Skip(1).First()
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode()
|
||||||
|
.AddHopFrame();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
debug/TestApplication/Properties/launchSettings.json
Normal file
23
debug/TestApplication/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5281",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7126;http://localhost:5281",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
debug/TestApplication/TestApplication.csproj
Normal file
20
debug/TestApplication/TestApplication.csproj
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</Project>
|
||||||
8
debug/TestApplication/appsettings.Development.json
Normal file
8
debug/TestApplication/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
debug/TestApplication/appsettings.json
Normal file
9
debug/TestApplication/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
38
debug/TestApplication/wwwroot/app.css
Normal file
38
debug/TestApplication/wwwroot/app.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
h1:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid.modified:not([type=checkbox]) {
|
||||||
|
outline: 1px solid #26b050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
outline: 1px solid #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-message {
|
||||||
|
color: #e50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary {
|
||||||
|
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||||
|
padding: 1rem 1rem 1rem 3.7rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary::after {
|
||||||
|
content: "An error has occurred."
|
||||||
|
}
|
||||||
|
|
||||||
|
.darker-border-checkbox.form-check-input {
|
||||||
|
border-color: #929292;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
11
src/HopFrame.Core/Configuration/HopFrameConfig.cs
Normal file
11
src/HopFrame.Core/Configuration/HopFrameConfig.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace HopFrame.Core.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configuration for the library
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HopFrameConfig {
|
||||||
|
/// The configurations for the table repositories
|
||||||
|
public IList<TableConfig> Tables { get; set; } = new List<TableConfig>();
|
||||||
|
|
||||||
|
internal HopFrameConfig() {}
|
||||||
|
}
|
||||||
93
src/HopFrame.Core/Configuration/PropertyConfig.cs
Normal file
93
src/HopFrame.Core/Configuration/PropertyConfig.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
namespace HopFrame.Core.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configuration for a single property
|
||||||
|
/// </summary>
|
||||||
|
public class PropertyConfig {
|
||||||
|
/// [GENERATED] The unique identifier for the property (usually the real property name in the model)
|
||||||
|
public required string Identifier { get; init; }
|
||||||
|
|
||||||
|
/// [GENERATED] The displayed name of the Property
|
||||||
|
public required string DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The real type of the property
|
||||||
|
public required Type Type { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The type as wich the property should be treated
|
||||||
|
public required PropertyType PropertyType { get; set; }
|
||||||
|
|
||||||
|
/// Determines if the property will appear in the table
|
||||||
|
public bool Listable { get; set; } = true;
|
||||||
|
|
||||||
|
/// Determines if the table can be sorted by the property
|
||||||
|
public bool Sortable { get; set; } = true;
|
||||||
|
|
||||||
|
/// Determines if the table can be searched by the property
|
||||||
|
public bool Searchable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the value of the property can be edited<br/>
|
||||||
|
/// (if true the value can still be set during creation)
|
||||||
|
/// </summary>
|
||||||
|
public bool Editable { get; set; } = true;
|
||||||
|
|
||||||
|
/// Determines if the property is visible in the creation or edit dialog
|
||||||
|
public bool Creatable { get; set; } = true;
|
||||||
|
|
||||||
|
/// [GENERATED] The place (from left to right) that the property will appear in the table and editor
|
||||||
|
public int OrderIndex { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The table that owns this property
|
||||||
|
public TableConfig Table { get; set; }
|
||||||
|
|
||||||
|
internal PropertyConfig() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to distinguish between different input types in the frontend. <br/>
|
||||||
|
/// Binary Format: First byte is used for additional properties, second byte identifies the real type
|
||||||
|
/// </summary>
|
||||||
|
[Flags]
|
||||||
|
public enum PropertyType : byte {
|
||||||
|
/// Used together with another type to indicate that the value can be null
|
||||||
|
Nullable = 0b10000000,
|
||||||
|
|
||||||
|
/// Used together with another type to indicate that the property is a relation
|
||||||
|
Relation = 0b01000000,
|
||||||
|
|
||||||
|
/// Used together with another type to indicate that the value is enumerable
|
||||||
|
List = 0b00100000,
|
||||||
|
|
||||||
|
/// Indicates that the value is numeric
|
||||||
|
Numeric = 0x01,
|
||||||
|
|
||||||
|
/// Indicates that the value is a boolean
|
||||||
|
Boolean = 0x02,
|
||||||
|
|
||||||
|
/// Indicates that the value is a timestamp
|
||||||
|
DateTime = 0x03,
|
||||||
|
|
||||||
|
/// Indicates that the value is a date
|
||||||
|
DateOnly = 0x04,
|
||||||
|
|
||||||
|
/// Indicates that the value is a time of day
|
||||||
|
TimeOnly = 0x05,
|
||||||
|
|
||||||
|
/// Indicates that the value is a list of fixed values
|
||||||
|
Enum = 0x06,
|
||||||
|
|
||||||
|
/// Indicates that the value is a string
|
||||||
|
Text = 0x07,
|
||||||
|
|
||||||
|
/// Indicates that the value is an email
|
||||||
|
Email = 0x08,
|
||||||
|
|
||||||
|
/// Indicates that the value is a long string
|
||||||
|
TextArea = 0x09,
|
||||||
|
|
||||||
|
/// Indicates that the value should be hidden
|
||||||
|
Password = 0x0A,
|
||||||
|
|
||||||
|
/// Indicates that the value is a phone number
|
||||||
|
PhoneNumber = 0x0B
|
||||||
|
}
|
||||||
35
src/HopFrame.Core/Configuration/TableConfig.cs
Normal file
35
src/HopFrame.Core/Configuration/TableConfig.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace HopFrame.Core.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configuration for a table
|
||||||
|
/// </summary>
|
||||||
|
public class TableConfig {
|
||||||
|
/// [GENERATED] The unique identifier for the table (usually the name of the model)
|
||||||
|
public required string Identifier { get; init; }
|
||||||
|
|
||||||
|
/// [GENERATED] The configurations for the properties of the model
|
||||||
|
public IList<PropertyConfig> Properties { get; set; } = new List<PropertyConfig>();
|
||||||
|
|
||||||
|
/// [GENERATED] The type of the model
|
||||||
|
public required Type TableType { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The type identifier for the repository
|
||||||
|
public required Type RepositoryType { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] the url of the table page
|
||||||
|
public required string Route { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The displayed name of the table
|
||||||
|
public required string DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// A short description for the table
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The place (from top to bottom) that the table will appear in on the sidebar
|
||||||
|
public int OrderIndex { get; set; }
|
||||||
|
|
||||||
|
/// [GENERATED] The identifier of the property that should be displayed if the model is used as a relation
|
||||||
|
public string? PreferredProperty { get; set; }
|
||||||
|
|
||||||
|
internal TableConfig() {}
|
||||||
|
}
|
||||||
70
src/HopFrame.Core/Configurators/HopFrameConfigurator.cs
Normal file
70
src/HopFrame.Core/Configurators/HopFrameConfigurator.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Configurators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configurator for the <see cref="HopFrameConfig"/>
|
||||||
|
/// </summary>
|
||||||
|
public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) {
|
||||||
|
/// The internal config that is modified
|
||||||
|
public HopFrameConfig Config { get; } = config;
|
||||||
|
|
||||||
|
internal IServiceCollection Services { get; } = services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new table to the configuration based on the provided repository
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TRepository">The repository that handles the table</typeparam>
|
||||||
|
/// <typeparam name="TModel">The type of the model</typeparam>
|
||||||
|
/// <param name="configurator">The configurator for the table</param>
|
||||||
|
public HopFrameConfigurator AddRepository<TRepository, TModel>(Action<TableConfigurator<TModel>>? configurator = null) where TRepository : IHopFrameRepository where TModel : class {
|
||||||
|
var table = ConfigurationHelper.InitializeTable(Config, typeof(TRepository), typeof(TModel));
|
||||||
|
Config.Tables.Add(table);
|
||||||
|
Services.TryAddScoped(typeof(TRepository));
|
||||||
|
configurator?.Invoke(new TableConfigurator<TModel>(table));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new table to the configuration
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The configuration for the table</param>
|
||||||
|
/// <param name="configurator">The configurator for the table</param>
|
||||||
|
/// <typeparam name="TModel">The model of the table</typeparam>
|
||||||
|
/// <exception cref="ArgumentException">Is thrown when configuration validation fails</exception>
|
||||||
|
public HopFrameConfigurator AddTable<TModel>(TableConfig config, Action<TableConfigurator<TModel>>? configurator = null) where TModel : class {
|
||||||
|
if (typeof(TModel) != config.TableType)
|
||||||
|
throw new ArgumentException($"Table type for table '{config.Identifier}' does not mach requested type '{typeof(TModel).Name}'!");
|
||||||
|
|
||||||
|
var errors = ConfigurationHelper.ValidateTable(Config, config).ToArray();
|
||||||
|
|
||||||
|
if (errors.Length != 0)
|
||||||
|
throw new ArgumentException($"Table '{config.Identifier}' has some validation errors:\n\t{string.Join("\n\t", errors)}");
|
||||||
|
|
||||||
|
Config.Tables.Add(config);
|
||||||
|
Services.TryAddScoped(config.RepositoryType);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/HopFrame.Core/Configurators/PropertyConfigurator.cs
Normal file
62
src/HopFrame.Core/Configurators/PropertyConfigurator.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Configurators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configurator for the <see cref="PropertyConfig"/>
|
||||||
|
/// </summary>
|
||||||
|
public class PropertyConfigurator(PropertyConfig config) {
|
||||||
|
/// The internal config that is modified
|
||||||
|
public PropertyConfig Config { get; } = config;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.DisplayName" />
|
||||||
|
public PropertyConfigurator SetDisplayName(string displayName) {
|
||||||
|
Config.DisplayName = displayName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.Listable" />
|
||||||
|
public PropertyConfigurator Listable(bool listable) {
|
||||||
|
Config.Listable = listable;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.Sortable" />
|
||||||
|
public PropertyConfigurator Sortable(bool sortable) {
|
||||||
|
Config.Sortable = sortable;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.Searchable" />
|
||||||
|
public PropertyConfigurator Searchable(bool searchable) {
|
||||||
|
Config.Searchable = searchable;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.Editable" />
|
||||||
|
public PropertyConfigurator Editable(bool editable) {
|
||||||
|
Config.Editable = editable;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.Creatable" />
|
||||||
|
public PropertyConfigurator Creatable(bool creatable) {
|
||||||
|
Config.Creatable = creatable;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="PropertyConfig.OrderIndex" />
|
||||||
|
public PropertyConfigurator SetOrderIndex(int index) {
|
||||||
|
Config.OrderIndex = index;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the property type. The predefined modifiers (like nullable) persist.
|
||||||
|
/// If the property is a list or any other generic type, please use the enumerated type.
|
||||||
|
/// </summary>
|
||||||
|
public PropertyConfigurator SetType(PropertyType type) {
|
||||||
|
Config.PropertyType = (PropertyType)(((byte)Config.PropertyType & 0xF0) | ((byte)type & 0x0F));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/HopFrame.Core/Configurators/TableConfigurator.cs
Normal file
71
src/HopFrame.Core/Configurators/TableConfigurator.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Configurators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configurator for the <see cref="TableConfig"/>
|
||||||
|
/// </summary>
|
||||||
|
public class TableConfigurator<TModel>(TableConfig config) where TModel : class {
|
||||||
|
/// The internal config that is modified
|
||||||
|
public TableConfig Config { get; } = config;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TableConfig.Route"/>
|
||||||
|
public TableConfigurator<TModel> SetRoute(string route) {
|
||||||
|
Config.Route = route;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TableConfig.DisplayName"/>
|
||||||
|
public TableConfigurator<TModel> SetDisplayName(string displayName) {
|
||||||
|
Config.DisplayName = displayName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TableConfig.Description"/>
|
||||||
|
public TableConfigurator<TModel> SetDescription(string description) {
|
||||||
|
Config.Description = description;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TableConfig.OrderIndex"/>
|
||||||
|
public TableConfigurator<TModel> SetOrderIndex(int index) {
|
||||||
|
Config.OrderIndex = index;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configurator for a property
|
||||||
|
public PropertyConfigurator Property(string identifier) {
|
||||||
|
var prop = Config.Properties
|
||||||
|
.FirstOrDefault(p => p.Identifier == identifier);
|
||||||
|
|
||||||
|
if (prop is null)
|
||||||
|
throw new ArgumentException($"No attribute '{identifier}' found in '{Config.Identifier}'!");
|
||||||
|
|
||||||
|
return new PropertyConfigurator(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Property(string)"/>
|
||||||
|
public PropertyConfigurator Property(Expression<Func<TModel, object>> propertyExpression) {
|
||||||
|
var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name;
|
||||||
|
var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName);
|
||||||
|
|
||||||
|
if (prop is null)
|
||||||
|
throw new ArgumentException($"No attribute '{propertyName}' found in '{Config.Identifier}'!");
|
||||||
|
|
||||||
|
return new PropertyConfigurator(prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TableConfig.PreferredProperty"/>
|
||||||
|
public TableConfigurator<TModel> SetPreferredProperty(Expression<Func<TModel, object>> propertyExpression) {
|
||||||
|
var propertyName = ExpressionHelper.GetPropertyInfo(propertyExpression).Name;
|
||||||
|
var prop = Config.Properties.FirstOrDefault(p => p.Identifier == propertyName);
|
||||||
|
|
||||||
|
if (prop is null)
|
||||||
|
throw new ArgumentException($"No attribute '{propertyName}' found in '{Config.Identifier}'!");
|
||||||
|
|
||||||
|
Config.PreferredProperty = prop.Identifier;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/HopFrame.Core/EFCore/DbConfigPopulator.cs
Normal file
34
src/HopFrame.Core/EFCore/DbConfigPopulator.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.EFCore;
|
||||||
|
|
||||||
|
internal static class DbConfigPopulator {
|
||||||
|
|
||||||
|
public static string ConfigureRepository(HopFrameConfig global, IServiceCollection services, Type contextType, PropertyInfo tableProperty) {
|
||||||
|
var modelType = tableProperty.PropertyType.GenericTypeArguments.First();
|
||||||
|
var repoType = typeof(EfCoreRepository<,>).MakeGenericType(modelType, contextType);
|
||||||
|
|
||||||
|
services.AddScoped(repoType);
|
||||||
|
|
||||||
|
var table = ConfigurationHelper.InitializeTable(global, repoType, modelType);
|
||||||
|
global.Tables.Add(table);
|
||||||
|
return table.Identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CheckForRelations(HopFrameConfig global, TableConfig table) {
|
||||||
|
foreach (var property in table.Properties) {
|
||||||
|
var type = property.Type;
|
||||||
|
|
||||||
|
if ((property.PropertyType & PropertyType.List) != 0 && type.IsGenericType) {
|
||||||
|
type = type.GenericTypeArguments.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.Tables.Any(t => t.TableType == type))
|
||||||
|
property.PropertyType |= PropertyType.Relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
53
src/HopFrame.Core/EFCore/EfCoreRepository.cs
Normal file
53
src/HopFrame.Core/EFCore/EfCoreRepository.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using HopFrame.Core.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.EFCore;
|
||||||
|
|
||||||
|
internal class EfCoreRepository<TModel, TContext>(TContext context, IConfigAccessor accessor) : HopFrameRepository<TModel> where TModel : class where TContext : DbContext {
|
||||||
|
|
||||||
|
public override async Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default) {
|
||||||
|
var set = context.Set<TModel>();
|
||||||
|
var query = set
|
||||||
|
.AsNoTracking()
|
||||||
|
.Skip(page * perPage)
|
||||||
|
.Take(perPage);
|
||||||
|
|
||||||
|
return await IncludeForeignKeys(query)
|
||||||
|
.ToArrayAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TModel> IncludeForeignKeys(IQueryable<TModel> query) {
|
||||||
|
var table = accessor.GetTableByType(typeof(TModel))!;
|
||||||
|
|
||||||
|
return table.Properties
|
||||||
|
.Where(p => (p.PropertyType & PropertyType.Relation) != 0)
|
||||||
|
.Aggregate(query, (current, property) => current.Include(property.Identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> CountAsync(CancellationToken ct = default) {
|
||||||
|
var set = context.Set<TModel>();
|
||||||
|
return await set.CountAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
44
src/HopFrame.Core/EFCore/HopFrameConfiguratorExtensions.cs
Normal file
44
src/HopFrame.Core/EFCore/HopFrameConfiguratorExtensions.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Configurators;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.EFCore;
|
||||||
|
|
||||||
|
/// Adds useful extensions to the <see cref="HopFrameConfigurator"/> to add managed <see cref="DbContext"/> repositories
|
||||||
|
public static class HopFrameConfiguratorExtensions {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds managed repositories for the selected (or all if none provided) tables
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configurator">The configurator for the current <see cref="HopFrameConfig"/></param>
|
||||||
|
/// <param name="includedTables">The tables that should be configured (if none are provided, all tables will be added)</param>
|
||||||
|
/// <typeparam name="TDbContext">The already configured and injectable database context</typeparam>
|
||||||
|
public static HopFrameConfigurator AddDbContext<TDbContext>(this HopFrameConfigurator configurator, params Expression<Func<TDbContext, object>>[] includedTables) where TDbContext : DbContext {
|
||||||
|
var contextType = typeof(TDbContext);
|
||||||
|
var properties = contextType.GetProperties()
|
||||||
|
.Where(p => p.PropertyType.IsGenericType)
|
||||||
|
.Where(p => p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||||
|
|
||||||
|
if (includedTables.Length != 0) {
|
||||||
|
properties = includedTables.Select(ExpressionHelper.GetPropertyInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableIdentifiers = new List<string>();
|
||||||
|
foreach (var tableProperty in properties) {
|
||||||
|
var identifier = DbConfigPopulator.ConfigureRepository(configurator.Config, configurator.Services, contextType, tableProperty);
|
||||||
|
tableIdentifiers.Add(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdTables = configurator.Config.Tables
|
||||||
|
.Where(t => tableIdentifiers.Contains(t.Identifier))
|
||||||
|
.ToArray();
|
||||||
|
foreach (var createdTable in createdTables) {
|
||||||
|
DbConfigPopulator.CheckForRelations(configurator.Config, createdTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return configurator;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
130
src/HopFrame.Core/Helpers/ConfigurationHelper.cs
Normal file
130
src/HopFrame.Core/Helpers/ConfigurationHelper.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
internal static class ConfigurationHelper {
|
||||||
|
|
||||||
|
public static TableConfig InitializeTable(HopFrameConfig global, Type repositoryType, Type modelType) {
|
||||||
|
var identifier = modelType.Name;
|
||||||
|
|
||||||
|
if (global.Tables.Any(t => t.Identifier == identifier))
|
||||||
|
identifier = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var config = new TableConfig {
|
||||||
|
RepositoryType = repositoryType,
|
||||||
|
TableType = modelType,
|
||||||
|
Identifier = identifier,
|
||||||
|
Route = modelType.Name.ToLower() + 's',
|
||||||
|
DisplayName = modelType.Name + 's',
|
||||||
|
OrderIndex = global.Tables.Count
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var property in modelType.GetProperties()) {
|
||||||
|
config.Properties.Add(InitializeProperty(config, property));
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
|
||||||
|
var identifier = property.Name;
|
||||||
|
|
||||||
|
if (table.Properties.Any(p => p.Identifier == identifier))
|
||||||
|
identifier = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var config = new PropertyConfig {
|
||||||
|
Identifier = identifier,
|
||||||
|
Type = property.PropertyType,
|
||||||
|
DisplayName = property.Name,
|
||||||
|
OrderIndex = table.Properties.Count,
|
||||||
|
PropertyType = InferPropertyType(property.PropertyType, property),
|
||||||
|
Table = table
|
||||||
|
};
|
||||||
|
|
||||||
|
if (property.CustomAttributes.Any(a => a.AttributeType == typeof(KeyAttribute)))
|
||||||
|
table.PreferredProperty = config.Identifier;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyType InferPropertyType(Type realType, PropertyInfo info) {
|
||||||
|
byte modifiers = 0;
|
||||||
|
|
||||||
|
if (Nullable.GetUnderlyingType(realType) != null) {
|
||||||
|
modifiers |= (byte)PropertyType.Nullable;
|
||||||
|
realType = Nullable.GetUnderlyingType(realType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(NullableAttribute))) {
|
||||||
|
modifiers |= (byte)PropertyType.Nullable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((realType.IsAssignableTo(typeof(IEnumerable)) || realType.IsAssignableTo(typeof(IEnumerable<>))) && realType != typeof(string)) {
|
||||||
|
modifiers |= (byte)PropertyType.List;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realType.IsGenericType) {
|
||||||
|
realType = realType.GenericTypeArguments.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = PropertyType.Text;
|
||||||
|
var realTypeCode = Type.GetTypeCode(realType);
|
||||||
|
|
||||||
|
if (realTypeCode == TypeCode.Boolean)
|
||||||
|
type = PropertyType.Boolean;
|
||||||
|
|
||||||
|
if (realTypeCode == TypeCode.DateTime)
|
||||||
|
type = PropertyType.DateTime;
|
||||||
|
|
||||||
|
if (realType == typeof(DateOnly))
|
||||||
|
type = PropertyType.DateOnly;
|
||||||
|
|
||||||
|
if (realType == typeof(TimeOnly))
|
||||||
|
type = PropertyType.TimeOnly;
|
||||||
|
|
||||||
|
if (realType.IsEnum)
|
||||||
|
type = PropertyType.Enum;
|
||||||
|
|
||||||
|
if (realType.IsNumeric())
|
||||||
|
type = PropertyType.Numeric;
|
||||||
|
|
||||||
|
if (info.CustomAttributes.Any(a => a.AttributeType == typeof(EmailAddressAttribute)))
|
||||||
|
type = PropertyType.Email;
|
||||||
|
|
||||||
|
return (PropertyType)((byte)type | modifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<string> ValidateTable(HopFrameConfig global, TableConfig config) {
|
||||||
|
if (global.Tables.Any(t => t.Identifier == config.Identifier))
|
||||||
|
yield return $"Table identifier '{config.Identifier}' is not unique";
|
||||||
|
|
||||||
|
if (config.TableType is null)
|
||||||
|
yield return "TableType cannot be null";
|
||||||
|
|
||||||
|
if (config.RepositoryType is null)
|
||||||
|
yield return "RepositoryType cannot be null";
|
||||||
|
|
||||||
|
if (config.Route is null)
|
||||||
|
yield return "Route cannot be null";
|
||||||
|
|
||||||
|
if (config.DisplayName is null)
|
||||||
|
yield return "DisplayName cannot be null";
|
||||||
|
|
||||||
|
foreach (var property in config.Properties) {
|
||||||
|
if (config.Properties.Count(p => p.Identifier == property.Identifier) > 1)
|
||||||
|
yield return $"Property identifier '{property.Identifier}' is not unique";
|
||||||
|
|
||||||
|
if (property.DisplayName is null)
|
||||||
|
yield return $"Property '{property.Identifier}': DisplayName cannot be null";
|
||||||
|
|
||||||
|
if (property.Type is null)
|
||||||
|
yield return $"Property '{property.Identifier}': Type cannot be null";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
27
src/HopFrame.Core/Helpers/ExpressionHelper.cs
Normal file
27
src/HopFrame.Core/Helpers/ExpressionHelper.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
internal static class ExpressionHelper {
|
||||||
|
public static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
|
||||||
|
if (propertyLambda.Body is not MemberExpression member) {
|
||||||
|
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.Member is not PropertyInfo propInfo) {
|
||||||
|
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = typeof(TSource);
|
||||||
|
if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
|
||||||
|
!type.IsSubclassOf(propInfo.ReflectedType)) {
|
||||||
|
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propInfo.Name is null)
|
||||||
|
throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property.");
|
||||||
|
|
||||||
|
return propInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/HopFrame.Core/Helpers/TypeExtensions.cs
Normal file
23
src/HopFrame.Core/Helpers/TypeExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
internal static class TypeExtensions {
|
||||||
|
public static bool IsNumeric(this Type o) {
|
||||||
|
if (o.IsEnum) return false;
|
||||||
|
switch (Type.GetTypeCode(o)) {
|
||||||
|
case TypeCode.Byte:
|
||||||
|
case TypeCode.SByte:
|
||||||
|
case TypeCode.UInt16:
|
||||||
|
case TypeCode.UInt32:
|
||||||
|
case TypeCode.UInt64:
|
||||||
|
case TypeCode.Int16:
|
||||||
|
case TypeCode.Int32:
|
||||||
|
case TypeCode.Int64:
|
||||||
|
case TypeCode.Decimal:
|
||||||
|
case TypeCode.Double:
|
||||||
|
case TypeCode.Single:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/HopFrame.Core/HopFrame.Core.csproj
Normal file
25
src/HopFrame.Core/HopFrame.Core.csproj
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<PackageId>HopFrame.Core</PackageId>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
|
<_Parameter1>HopFrame.Tests.Core</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
51
src/HopFrame.Core/Repositories/HopFrameRepository.cs
Normal file
51
src/HopFrame.Core/Repositories/HopFrameRepository.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Collections;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Repositories;
|
||||||
|
|
||||||
|
/// The base repository that provides access to the model dataset
|
||||||
|
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : class {
|
||||||
|
|
||||||
|
/// <inheritdoc cref="LoadPageGenericAsync"/>
|
||||||
|
public abstract Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract Task<int> CountAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="SearchGenericAsync"/>
|
||||||
|
public abstract Task<IEnumerable<TModel>> SearchAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="CreateGenericAsync"/>
|
||||||
|
public abstract Task CreateAsync(TModel entry, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="UpdateGenericAsync"/>
|
||||||
|
public abstract Task UpdateAsync(TModel entry, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DeleteGenericAsync"/>
|
||||||
|
public abstract Task DeleteAsync(TModel entry, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
|
||||||
|
return await LoadPageAsync(page, perPage, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
|
||||||
|
return await SearchAsync(searchTerm, page, perPage, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task CreateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
return CreateAsync((TModel)entry, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
return UpdateAsync((TModel)entry, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
return DeleteAsync((TModel)entry, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
46
src/HopFrame.Core/Repositories/IHopFrameRepository.cs
Normal file
46
src/HopFrame.Core/Repositories/IHopFrameRepository.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
|
||||||
|
namespace HopFrame.Core.Repositories;
|
||||||
|
|
||||||
|
/// The generic repository that provides access to the model dataset
|
||||||
|
public interface IHopFrameRepository {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a whole page of entries
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="page">The index of the current page (starts at 0)</param>
|
||||||
|
/// <param name="perPage">The amount of entries that should be loaded</param>
|
||||||
|
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the total amount of entries in the dataset
|
||||||
|
/// </summary>
|
||||||
|
public Task<int> CountAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches through the whole dataset and returns a page of matching entries
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchTerm">The search text provided by the user</param>
|
||||||
|
/// <param name="page">The index of the current page (starts at 0)</param>
|
||||||
|
/// <param name="perPage">The amount of entries that should be loaded</param>
|
||||||
|
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct);
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the newly created entry to the dataset
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The entry that needs to be saved</param>
|
||||||
|
public Task CreateGenericAsync(object entry, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the changes made to the entry to the dataset
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The modified entry</param>
|
||||||
|
public Task UpdateGenericAsync(object entry, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the provided entry from the dataset
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The entry that needs to be deleted</param>
|
||||||
|
public Task DeleteGenericAsync(object entry, CancellationToken ct);
|
||||||
|
|
||||||
|
}
|
||||||
24
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal file
24
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Configurators;
|
||||||
|
using HopFrame.Core.Services;
|
||||||
|
using HopFrame.Core.Services.Implementation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Core;
|
||||||
|
|
||||||
|
/// 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
33
src/HopFrame.Core/Services/IConfigAccessor.cs
Normal file
33
src/HopFrame.Core/Services/IConfigAccessor.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Services;
|
||||||
|
|
||||||
|
/// A service used to access configs and repositories provided by the <see cref="HopFrameConfig"/>
|
||||||
|
public interface IConfigAccessor {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches through the config and returns the table with the specified identifier if it exists
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="identifier">The identifier of the table</param>
|
||||||
|
public TableConfig? GetTableByIdentifier(string identifier);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches through the config and returns the table with the specified route if it exists
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="route">The route of the table</param>
|
||||||
|
public TableConfig? GetTableByRoute(string route);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches through the config and returns the table with the specified type if it exists
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The model type for the table</param>
|
||||||
|
public TableConfig? GetTableByType(Type type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the repository for the specified table
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="table">The table to load the repository for</param>
|
||||||
|
public IHopFrameRepository LoadRepository(TableConfig table);
|
||||||
|
|
||||||
|
}
|
||||||
38
src/HopFrame.Core/Services/IEntityAccessor.cs
Normal file
38
src/HopFrame.Core/Services/IEntityAccessor.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 string? GetValue(object model, PropertyConfig property);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the property to be displayed properly
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The value of the property</param>
|
||||||
|
/// <param name="property">The property that shall be extracted</param>
|
||||||
|
public string? FormatValue(object? value, 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 void SetValue(object model, PropertyConfig property, object value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sorts the provided dataset by the specified property
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="data">The dataset that needs to be sorted</param>
|
||||||
|
/// <param name="property">The property that defines the sort order</param>
|
||||||
|
/// <param name="descending">Determines if the resulting order should be flipped</param>
|
||||||
|
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false);
|
||||||
|
|
||||||
|
}
|
||||||
25
src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs
Normal file
25
src/HopFrame.Core/Services/Implementation/ConfigAccessor.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Services.Implementation;
|
||||||
|
|
||||||
|
internal sealed class ConfigAccessor(HopFrameConfig config, IServiceProvider services) : IConfigAccessor {
|
||||||
|
|
||||||
|
public TableConfig? GetTableByIdentifier(string identifier) {
|
||||||
|
return config.Tables.FirstOrDefault(t => t.Identifier == identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TableConfig? GetTableByRoute(string route) {
|
||||||
|
return config.Tables.FirstOrDefault(t => t.Route == route);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TableConfig? GetTableByType(Type type) {
|
||||||
|
return config.Tables.FirstOrDefault(t => t.TableType == type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IHopFrameRepository LoadRepository(TableConfig table) {
|
||||||
|
return (IHopFrameRepository)services.GetRequiredService(table.RepositoryType);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
91
src/HopFrame.Core/Services/Implementation/EntityAccessor.cs
Normal file
91
src/HopFrame.Core/Services/Implementation/EntityAccessor.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Services.Implementation;
|
||||||
|
|
||||||
|
internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
|
||||||
|
|
||||||
|
public string? GetValue(object model, PropertyConfig property) {
|
||||||
|
var prop = model.GetType().GetProperty(property.Identifier);
|
||||||
|
if (prop is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var value = prop.GetValue(model);
|
||||||
|
return FormatValue(value, property);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? FormatValue(object? value, PropertyConfig property) {
|
||||||
|
if (value is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ((property.PropertyType & PropertyType.List) != 0) {
|
||||||
|
return (value as IEnumerable<object>)!.Count().ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((property.PropertyType & PropertyType.Relation) != 0) {
|
||||||
|
var table = accessor.GetTableByType(property.Type);
|
||||||
|
if (table?.PreferredProperty != null) {
|
||||||
|
var tableProp = table.Properties.First(p => p.Identifier == table.PreferredProperty);
|
||||||
|
return GetValue(value, tableProp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetValue(object model, PropertyConfig property, object value) {
|
||||||
|
var prop = model.GetType().GetProperty(property.Identifier);
|
||||||
|
if (prop is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (value.GetType() != property.Type)
|
||||||
|
value = Convert.ChangeType(value, property.Type);
|
||||||
|
|
||||||
|
prop.SetValue(model, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<object> SortDataByProperty(IEnumerable<object> data, PropertyConfig property, bool descending = false) {
|
||||||
|
var prop = property.Table.TableType.GetProperty(property.Identifier);
|
||||||
|
if (prop is null)
|
||||||
|
return data;
|
||||||
|
|
||||||
|
var parameter = Expression.Parameter(property.Table.TableType);
|
||||||
|
Expression expression = Expression.Property(parameter, prop);
|
||||||
|
var targetType = prop.PropertyType;
|
||||||
|
|
||||||
|
if ((property.PropertyType & PropertyType.Relation) != 0) {
|
||||||
|
var relationTable = accessor.GetTableByType(property.Type);
|
||||||
|
PropertyInfo? relationPropInfo = null;
|
||||||
|
|
||||||
|
if (relationTable?.PreferredProperty != null) {
|
||||||
|
var relationProp = relationTable.Properties.First(p => p.Identifier == relationTable.PreferredProperty);
|
||||||
|
|
||||||
|
if ((relationProp.PropertyType & PropertyType.List) == 0)
|
||||||
|
relationPropInfo = relationProp.Type.GetProperty(relationProp.Identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationPropInfo == null) {
|
||||||
|
var formatMethod = GetType().GetMethod(nameof(FormatValue))!;
|
||||||
|
targetType = typeof(string);
|
||||||
|
expression = Expression.Call(Expression.Constant(this), formatMethod, expression, Expression.Constant(property));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
targetType = relationPropInfo.PropertyType;
|
||||||
|
expression = Expression.Property(expression, relationPropInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lambda = Expression.Lambda(expression, parameter);
|
||||||
|
|
||||||
|
var methodName = descending ? nameof(Enumerable.OrderByDescending) : nameof(Enumerable.OrderBy);
|
||||||
|
var method = typeof(Enumerable)
|
||||||
|
.GetMethods()
|
||||||
|
.Single(m => m.Name == methodName && m.GetParameters().Length == 2)
|
||||||
|
.MakeGenericMethod(property.Table.TableType, targetType);
|
||||||
|
|
||||||
|
var result = method.Invoke(null, [data, lambda.Compile()]);
|
||||||
|
return (IEnumerable<object>)result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
src/HopFrame.Web/Components/App.razor
Normal file
22
src/HopFrame.Web/Components/App.razor
Normal 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>
|
||||||
11
src/HopFrame.Web/Components/CancellableComponent.cs
Normal file
11
src/HopFrame.Web/Components/CancellableComponent.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/HopFrame.Web/Components/Components/Card.razor
Normal file
33
src/HopFrame.Web/Components/Components/Card.razor
Normal 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; }
|
||||||
|
}
|
||||||
40
src/HopFrame.Web/Components/Components/Sidebar.razor
Normal file
40
src/HopFrame.Web/Components/Components/Sidebar.razor
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
61
src/HopFrame.Web/Components/Components/Table.razor
Normal file
61
src/HopFrame.Web/Components/Components/Table.razor
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@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: 600px">
|
||||||
|
<MudButton OnClick="@(Manager.ReloadServerData)">Reload</MudButton>
|
||||||
|
<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>
|
||||||
|
<MudTableSortLabel
|
||||||
|
T="object"
|
||||||
|
@ref="SortDirections[prop.Identifier]"
|
||||||
|
SortDirectionChanged="@(dir => OnSort(prop, dir))"
|
||||||
|
Enabled="@prop.Sortable">
|
||||||
|
@prop.DisplayName
|
||||||
|
</MudTableSortLabel>
|
||||||
|
</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>
|
||||||
102
src/HopFrame.Web/Components/Components/Table.razor.cs
Normal file
102
src/HopFrame.Web/Components/Components/Table.razor.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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!;
|
||||||
|
|
||||||
|
private Dictionary<string, MudTableSortLabel<object>> SortDirections { get; set; } = new();
|
||||||
|
|
||||||
|
private KeyValuePair<string, SortDirection>? _currentSort;
|
||||||
|
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnInitialized() {
|
||||||
|
base.OnInitialized();
|
||||||
|
|
||||||
|
Repository = configAccessor.LoadRepository(Config);
|
||||||
|
|
||||||
|
OrderedProperties = Config.Properties
|
||||||
|
.Where(p => p.Listable)
|
||||||
|
.OrderBy(p => p.OrderIndex)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var property in OrderedProperties) {
|
||||||
|
SortDirections.Add(property.Identifier, null!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Dictionary<string, string>> PrepareData(object[] entries) {
|
||||||
|
var list = new List<Dictionary<string, string>>();
|
||||||
|
|
||||||
|
foreach (var entry in entries) {
|
||||||
|
var dict = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in OrderedProperties) {
|
||||||
|
dict.Add(prop.Identifier, accessor.GetValue(entry, prop) ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TableData<Dictionary<string, string>>> Reload(TableState state, CancellationToken ct) {
|
||||||
|
IEnumerable<object> entries;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_searchText))
|
||||||
|
entries = await Repository.LoadPageGenericAsync(state.Page, state.PageSize, ct);
|
||||||
|
else
|
||||||
|
entries = await Repository.SearchGenericAsync(_searchText, state.Page, state.PageSize, ct);
|
||||||
|
|
||||||
|
if (_currentSort.HasValue) {
|
||||||
|
var sortProp = Config.Properties.First(p => p.Identifier == _currentSort.Value.Key);
|
||||||
|
entries = accessor.SortDataByProperty(entries, sortProp, _currentSort.Value.Value == SortDirection.Descending);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = PrepareData(entries.ToArray());
|
||||||
|
var total = await Repository.CountAsync(ct);
|
||||||
|
|
||||||
|
return new TableData<Dictionary<string, string>> {
|
||||||
|
TotalItems = total,
|
||||||
|
Items = data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSearch(string searchText) {
|
||||||
|
_searchText = searchText;
|
||||||
|
await Manager.ReloadServerData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSort(PropertyConfig property, SortDirection direction) {
|
||||||
|
if (direction != SortDirection.None) {
|
||||||
|
foreach (var reference in SortDirections
|
||||||
|
.Where(d => d.Key != property.Identifier)) {
|
||||||
|
#pragma warning disable BL0005
|
||||||
|
reference.Value.SortDirection = SortDirection.None;
|
||||||
|
#pragma warning restore BL0005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction == SortDirection.None) {
|
||||||
|
_currentSort = null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_currentSort = new(property.Identifier, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Manager.ReloadServerData();
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/HopFrame.Web/Components/Components/Topbar.razor
Normal file
3
src/HopFrame.Web/Components/Components/Topbar.razor
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<MudAppBar Dense="true" Elevation="0">
|
||||||
|
HopFrame
|
||||||
|
</MudAppBar>
|
||||||
18
src/HopFrame.Web/Components/HopFrameLayout.razor
Normal file
18
src/HopFrame.Web/Components/HopFrameLayout.razor
Normal 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>
|
||||||
51
src/HopFrame.Web/Components/Pages/HomePage.razor
Normal file
51
src/HopFrame.Web/Components/Pages/HomePage.razor
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
12
src/HopFrame.Web/Components/Pages/TablePage.razor
Normal file
12
src/HopFrame.Web/Components/Pages/TablePage.razor
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@page "/admin/{TableRoute}"
|
||||||
|
@using HopFrame.Web.Components.Components
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@layout HopFrameLayout
|
||||||
|
|
||||||
|
<MudPopoverProvider />
|
||||||
|
<MudDialogProvider />
|
||||||
|
<MudSnackbarProvider/>
|
||||||
|
|
||||||
|
<PageTitle>HopFrame - @Table.DisplayName</PageTitle>
|
||||||
|
|
||||||
|
<Table Config="Table"></Table>
|
||||||
27
src/HopFrame.Web/Components/Pages/TablePage.razor.cs
Normal file
27
src/HopFrame.Web/Components/Pages/TablePage.razor.cs
Normal 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) : ComponentBase {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/HopFrame.Web/HopFrame.Web.csproj
Normal file
30
src/HopFrame.Web/HopFrame.Web.csproj
Normal 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>
|
||||||
43
src/HopFrame.Web/ServiceCollectionExtensions.cs
Normal file
43
src/HopFrame.Web/ServiceCollectionExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
8
src/HopFrame.Web/_Imports.razor
Normal file
8
src/HopFrame.Web/_Imports.razor
Normal 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
|
||||||
3
src/HopFrame.Web/wwwroot/hopframe.css
Normal file
3
src/HopFrame.Web/wwwroot/hopframe.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.mud-card-header-avatar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Configurators;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Configurators;
|
||||||
|
|
||||||
|
public class HopFrameConfiguratorTests {
|
||||||
|
private class TestRepository : IHopFrameRepository {
|
||||||
|
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task<int> CountAsync(CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task CreateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HopFrameConfig CreateConfig()
|
||||||
|
=> new HopFrameConfig { Tables = new List<TableConfig>() };
|
||||||
|
|
||||||
|
private TableConfig CreateValidTable<TModel>()
|
||||||
|
=> new TableConfig {
|
||||||
|
Identifier = typeof(TModel).Name,
|
||||||
|
TableType = typeof(TModel),
|
||||||
|
RepositoryType = typeof(TestRepository),
|
||||||
|
Route = typeof(TModel).Name.ToLower(),
|
||||||
|
DisplayName = typeof(TModel).Name,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Id",
|
||||||
|
DisplayName = "Id",
|
||||||
|
Type = typeof(int),
|
||||||
|
OrderIndex = 0,
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// AddRepository<TRepository, TModel>
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddRepository_AddsTableToConfig() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddRepository<TestRepository, TestModel>();
|
||||||
|
|
||||||
|
Assert.Single(config.Tables);
|
||||||
|
Assert.Equal(typeof(TestModel), config.Tables[0].TableType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddRepository_RegistersRepositoryInServices() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddRepository<TestRepository, TestModel>();
|
||||||
|
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(TestRepository));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddRepository_InvokesConfiguratorAction() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
bool invoked = false;
|
||||||
|
|
||||||
|
configurator.AddRepository<TestRepository, TestModel>(_ => { invoked = true; });
|
||||||
|
|
||||||
|
Assert.True(invoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// AddTable<TModel>
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTable_AddsValidTableToConfig() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var table = CreateValidTable<TestModel>();
|
||||||
|
|
||||||
|
configurator.AddTable<TestModel>(table);
|
||||||
|
|
||||||
|
Assert.Single(config.Tables);
|
||||||
|
Assert.Equal(table, config.Tables[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTable_RegistersRepositoryType() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var table = CreateValidTable<TestModel>();
|
||||||
|
|
||||||
|
configurator.AddTable<TestModel>(table);
|
||||||
|
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(TestRepository));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTable_Throws_WhenTableTypeDoesNotMatch() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var table = CreateValidTable<TestModel>();
|
||||||
|
table.TableType = typeof(string); // falscher Typ
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() =>
|
||||||
|
configurator.AddTable<TestModel>(table));
|
||||||
|
|
||||||
|
Assert.Contains("does not mach requested type", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTable_Throws_WhenValidationFails() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var table = CreateValidTable<TestModel>();
|
||||||
|
table.DisplayName = null!; // invalid
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() =>
|
||||||
|
configurator.AddTable<TestModel>(table));
|
||||||
|
|
||||||
|
Assert.Contains("validation errors", ex.Message);
|
||||||
|
Assert.Contains("DisplayName cannot be null", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddTable_InvokesConfiguratorAction() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var table = CreateValidTable<TestModel>();
|
||||||
|
|
||||||
|
bool invoked = false;
|
||||||
|
|
||||||
|
configurator.AddTable<TestModel>(table, _ => { invoked = true; });
|
||||||
|
|
||||||
|
Assert.True(invoked);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Configurators;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Configurators;
|
||||||
|
|
||||||
|
public class PropertyConfiguratorTests {
|
||||||
|
private PropertyConfig CreateConfig(PropertyType type)
|
||||||
|
=> new PropertyConfig {
|
||||||
|
Identifier = "Test",
|
||||||
|
DisplayName = "Test",
|
||||||
|
Type = typeof(string),
|
||||||
|
OrderIndex = 0,
|
||||||
|
PropertyType = type
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetType_ReplacesBaseType_AndPreservesModifiers() {
|
||||||
|
// Arrange: Nullable + List + Numeric
|
||||||
|
var original = PropertyType.Numeric | PropertyType.Nullable | PropertyType.List;
|
||||||
|
var config = CreateConfig(original);
|
||||||
|
|
||||||
|
var configurator = new PropertyConfigurator(config);
|
||||||
|
|
||||||
|
// Act: change base type to Text
|
||||||
|
configurator.SetType(PropertyType.Text);
|
||||||
|
|
||||||
|
// Assert: modifiers remain, base type replaced
|
||||||
|
Assert.Equal(
|
||||||
|
PropertyType.Text | PropertyType.Nullable | PropertyType.List,
|
||||||
|
config.PropertyType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetType_DoesNotAffectModifiers_WhenSettingSameBaseType() {
|
||||||
|
var original = PropertyType.Boolean | PropertyType.Nullable;
|
||||||
|
var config = CreateConfig(original);
|
||||||
|
|
||||||
|
var configurator = new PropertyConfigurator(config);
|
||||||
|
|
||||||
|
configurator.SetType(PropertyType.Boolean);
|
||||||
|
|
||||||
|
Assert.Equal(original, config.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetType_CanChangeEnumToNumeric_WhileKeepingModifiers() {
|
||||||
|
var original = PropertyType.Enum | PropertyType.List;
|
||||||
|
var config = CreateConfig(original);
|
||||||
|
|
||||||
|
var configurator = new PropertyConfigurator(config);
|
||||||
|
|
||||||
|
configurator.SetType(PropertyType.Numeric);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
PropertyType.Numeric | PropertyType.List,
|
||||||
|
config.PropertyType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetType_CanChangeToEmail_AndPreserveNullable() {
|
||||||
|
var original = PropertyType.Text | PropertyType.Nullable;
|
||||||
|
var config = CreateConfig(original);
|
||||||
|
|
||||||
|
var configurator = new PropertyConfigurator(config);
|
||||||
|
|
||||||
|
configurator.SetType(PropertyType.Email);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
PropertyType.Email | PropertyType.Nullable,
|
||||||
|
config.PropertyType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetType_ReturnsConfigurator_ForFluentApi() {
|
||||||
|
var config = CreateConfig(PropertyType.Text);
|
||||||
|
var configurator = new PropertyConfigurator(config);
|
||||||
|
|
||||||
|
var result = configurator.SetType(PropertyType.Numeric);
|
||||||
|
|
||||||
|
Assert.Same(configurator, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
tests/HopFrame.Tests.Core/EFCore/DbConfigPopulatorTests.cs
Normal file
147
tests/HopFrame.Tests.Core/EFCore/DbConfigPopulatorTests.cs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.EFCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.EFCore;
|
||||||
|
|
||||||
|
public class DbConfigPopulatorTests {
|
||||||
|
private class TestContext { }
|
||||||
|
|
||||||
|
private class TestModel {
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OtherModel {
|
||||||
|
public int X { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private HopFrameConfig CreateConfig(params TableConfig[] tables)
|
||||||
|
=> new HopFrameConfig { Tables = tables.ToList() };
|
||||||
|
|
||||||
|
private TableConfig CreateTable(Type modelType)
|
||||||
|
=> new TableConfig {
|
||||||
|
Identifier = modelType.Name,
|
||||||
|
TableType = modelType,
|
||||||
|
RepositoryType = typeof(object),
|
||||||
|
Route = modelType.Name.ToLower(),
|
||||||
|
DisplayName = modelType.Name,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Id",
|
||||||
|
DisplayName = "Id",
|
||||||
|
Type = typeof(int),
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// ConfigureRepository
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigureRepository_RegistersRepositoryType() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var global = CreateConfig();
|
||||||
|
|
||||||
|
var prop = typeof(TestDbContext).GetProperty(nameof(TestDbContext.TestModels))!;
|
||||||
|
|
||||||
|
var identifier = DbConfigPopulator.ConfigureRepository(global, services, typeof(TestDbContext), prop);
|
||||||
|
|
||||||
|
var repoType = typeof(EfCoreRepository<TestModel, TestDbContext>);
|
||||||
|
|
||||||
|
Assert.Contains(services, d => d.ServiceType == repoType);
|
||||||
|
Assert.Single(global.Tables);
|
||||||
|
Assert.Equal(identifier, global.Tables[0].Identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigureRepository_AddsTableToGlobalConfig() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var global = CreateConfig();
|
||||||
|
|
||||||
|
var prop = typeof(TestDbContext).GetProperty(nameof(TestDbContext.TestModels))!;
|
||||||
|
|
||||||
|
DbConfigPopulator.ConfigureRepository(global, services, typeof(TestDbContext), prop);
|
||||||
|
|
||||||
|
Assert.Single(global.Tables);
|
||||||
|
Assert.Equal(typeof(TestModel), global.Tables[0].TableType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestDbContext : DbContext {
|
||||||
|
public List<TestModel> TestModels { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// CheckForRelations
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
private class RelationModel {
|
||||||
|
public OtherModel Single { get; set; } = new();
|
||||||
|
public List<OtherModel> Many { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckForRelations_SetsRelationFlag_ForSingleReference() {
|
||||||
|
var otherTable = CreateTable(typeof(OtherModel));
|
||||||
|
var relationTable = CreateTable(typeof(RelationModel));
|
||||||
|
|
||||||
|
relationTable.Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Single",
|
||||||
|
DisplayName = "Single",
|
||||||
|
Type = typeof(OtherModel),
|
||||||
|
PropertyType = PropertyType.Text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var global = CreateConfig(otherTable, relationTable);
|
||||||
|
|
||||||
|
DbConfigPopulator.CheckForRelations(global, relationTable);
|
||||||
|
|
||||||
|
Assert.True((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckForRelations_SetsRelationFlag_ForListReference() {
|
||||||
|
var otherTable = CreateTable(typeof(OtherModel));
|
||||||
|
var relationTable = CreateTable(typeof(RelationModel));
|
||||||
|
|
||||||
|
relationTable.Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Many",
|
||||||
|
DisplayName = "Many",
|
||||||
|
Type = typeof(List<OtherModel>),
|
||||||
|
PropertyType = PropertyType.List
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var global = CreateConfig(otherTable, relationTable);
|
||||||
|
|
||||||
|
DbConfigPopulator.CheckForRelations(global, relationTable);
|
||||||
|
|
||||||
|
Assert.True((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckForRelations_DoesNotSetRelationFlag_WhenNoMatchingTableExists() {
|
||||||
|
var relationTable = CreateTable(typeof(RelationModel));
|
||||||
|
|
||||||
|
relationTable.Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Single",
|
||||||
|
DisplayName = "Single",
|
||||||
|
Type = typeof(OtherModel),
|
||||||
|
PropertyType = PropertyType.Text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var global = CreateConfig(relationTable);
|
||||||
|
|
||||||
|
DbConfigPopulator.CheckForRelations(global, relationTable);
|
||||||
|
|
||||||
|
Assert.False((relationTable.Properties[0].PropertyType & PropertyType.Relation) != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Configurators;
|
||||||
|
using HopFrame.Core.EFCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.EFCore;
|
||||||
|
|
||||||
|
public class HopFrameConfiguratorExtensionsTests {
|
||||||
|
private class TestModelA {
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestModelB {
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestDbContext : DbContext {
|
||||||
|
public DbSet<TestModelA> A { get; set; } = null!;
|
||||||
|
public DbSet<TestModelB> B { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HopFrameConfig CreateConfig()
|
||||||
|
=> new HopFrameConfig { Tables = new List<TableConfig>() };
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// AddDbContext - all tables
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_AddsAllDbSets_WhenNoFilterProvided() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddDbContext<TestDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(2, config.Tables.Count);
|
||||||
|
Assert.Contains(config.Tables, t => t.TableType == typeof(TestModelA));
|
||||||
|
Assert.Contains(config.Tables, t => t.TableType == typeof(TestModelB));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_RegistersRepositoriesForAllDbSets() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddDbContext<TestDbContext>();
|
||||||
|
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelA, TestDbContext>));
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelB, TestDbContext>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// AddDbContext - filtered tables
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_UsesOnlyIncludedTables_WhenFilterProvided() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddDbContext<TestDbContext>(ctx => ctx.A
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Single(config.Tables);
|
||||||
|
Assert.Equal(typeof(TestModelA), config.Tables[0].TableType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_RegistersOnlyFilteredRepositories() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddDbContext<TestDbContext>(ctx => ctx.A
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelA, TestDbContext>));
|
||||||
|
Assert.DoesNotContain(services, d => d.ServiceType == typeof(EfCoreRepository<TestModelB, TestDbContext>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Relation detection
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
private class RelationModel {
|
||||||
|
public TestModelA Single { get; set; } = null!;
|
||||||
|
public List<TestModelB> Many { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RelationDbContext : DbContext {
|
||||||
|
public DbSet<TestModelA> A { get; set; } = null!;
|
||||||
|
public DbSet<TestModelB> B { get; set; } = null!;
|
||||||
|
public DbSet<RelationModel> R { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_ChecksRelationsForCreatedTables() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
configurator.AddDbContext<RelationDbContext>();
|
||||||
|
|
||||||
|
var relationTable = config.Tables.Single(t => t.TableType == typeof(RelationModel));
|
||||||
|
|
||||||
|
// At least one property should have the Relation flag
|
||||||
|
Assert.Contains(relationTable.Properties, p => (p.PropertyType & PropertyType.Relation) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Fluent API
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDbContext_ReturnsConfigurator() {
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var config = CreateConfig();
|
||||||
|
var configurator = new HopFrameConfigurator(config, services);
|
||||||
|
|
||||||
|
var result = configurator.AddDbContext<TestDbContext>();
|
||||||
|
|
||||||
|
Assert.Same(configurator, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
378
tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs
Normal file
378
tests/HopFrame.Tests.Core/Helpers/ConfigurationHelperTests.cs
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Helpers;
|
||||||
|
|
||||||
|
public class ConfigurationHelperTests {
|
||||||
|
private class PropertyTypeModel {
|
||||||
|
public int Number { get; set; }
|
||||||
|
public int? NullableNumber { get; set; }
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
public bool Flag { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public TimeOnly Time { get; set; }
|
||||||
|
public TestEnum EnumValue { get; set; }
|
||||||
|
public List<int> Numbers { get; set; } = new();
|
||||||
|
public IEnumerable<string> Strings { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[EmailAddress]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestEnum {
|
||||||
|
A,
|
||||||
|
B
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private HopFrameConfig CreateGlobal(params TableConfig[] tables)
|
||||||
|
=> new HopFrameConfig { Tables = tables.ToList() };
|
||||||
|
|
||||||
|
private TableConfig CreateValidTable()
|
||||||
|
=> new TableConfig {
|
||||||
|
Identifier = "Test",
|
||||||
|
TableType = typeof(string),
|
||||||
|
RepositoryType = typeof(string),
|
||||||
|
Route = "/test",
|
||||||
|
DisplayName = "Test Table",
|
||||||
|
Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Prop1",
|
||||||
|
DisplayName = "Property 1",
|
||||||
|
Type = typeof(int),
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private TableConfig CreateDummyTable(string identifier)
|
||||||
|
=> new TableConfig {
|
||||||
|
Identifier = identifier,
|
||||||
|
TableType = typeof(object),
|
||||||
|
RepositoryType = typeof(object),
|
||||||
|
Route = identifier.ToLower(),
|
||||||
|
DisplayName = identifier,
|
||||||
|
OrderIndex = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_UsesModelNameAsIdentifier_WhenUnique() {
|
||||||
|
var global = CreateGlobal();
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Equal("TestModel", config.Identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_GeneratesGuid_WhenIdentifierAlreadyExists() {
|
||||||
|
var existing = CreateDummyTable("TestModel");
|
||||||
|
var global = CreateGlobal(existing);
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.NotEqual("TestModel", config.Identifier);
|
||||||
|
Assert.True(Guid.TryParse(config.Identifier, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_SetsBasicFieldsCorrectly() {
|
||||||
|
var global = CreateGlobal();
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Equal(typeof(string), config.RepositoryType);
|
||||||
|
Assert.Equal(typeof(TestModel), config.TableType);
|
||||||
|
Assert.Equal("testmodels", config.Route);
|
||||||
|
Assert.Equal("TestModels", config.DisplayName);
|
||||||
|
Assert.Equal(0, config.OrderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_SetsOrderIndex_ToCurrentTableCount() {
|
||||||
|
var global = CreateGlobal(
|
||||||
|
CreateDummyTable("A"),
|
||||||
|
CreateDummyTable("B")
|
||||||
|
);
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Equal(2, config.OrderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_CreatesPropertyConfigs_ForAllModelProperties() {
|
||||||
|
var global = CreateGlobal();
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Equal(2, config.Properties.Count);
|
||||||
|
Assert.Contains(config.Properties, p => p.Identifier == "Id");
|
||||||
|
Assert.Contains(config.Properties, p => p.Identifier == "Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeTable_SetsPropertyTypes_ForAllProperties() {
|
||||||
|
var global = CreateGlobal();
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeTable(global, typeof(string), typeof(PropertyTypeModel));
|
||||||
|
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Numeric);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Numeric | PropertyType.Nullable));
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Boolean);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.DateTime);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.DateOnly);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.TimeOnly);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Enum);
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Numeric | PropertyType.List));
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == (PropertyType.Text | PropertyType.List));
|
||||||
|
Assert.Contains(config.Properties, p => p.PropertyType == PropertyType.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeProperty_UsesPropertyNameAsIdentifier_WhenUnique() {
|
||||||
|
var table = CreateDummyTable("T");
|
||||||
|
var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!;
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeProperty(table, property);
|
||||||
|
|
||||||
|
Assert.Equal("Id", config.Identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeProperty_GeneratesGuid_WhenIdentifierAlreadyExists() {
|
||||||
|
var table = CreateDummyTable("T");
|
||||||
|
table.Properties.Add(new PropertyConfig {
|
||||||
|
Identifier = "Id", Type = typeof(int), DisplayName = "Id", OrderIndex = 0,
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
});
|
||||||
|
|
||||||
|
var property = typeof(TestModel).GetProperty(nameof(TestModel.Id))!;
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeProperty(table, property);
|
||||||
|
|
||||||
|
Assert.NotEqual("Id", config.Identifier);
|
||||||
|
Assert.True(Guid.TryParse(config.Identifier, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeProperty_SetsBasicFieldsCorrectly() {
|
||||||
|
var table = CreateDummyTable("T");
|
||||||
|
var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!;
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeProperty(table, property);
|
||||||
|
|
||||||
|
Assert.Equal("Name", config.DisplayName);
|
||||||
|
Assert.Equal(typeof(string), config.Type);
|
||||||
|
Assert.Equal(0, config.OrderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeProperty_SetsOrderIndex_ToCurrentPropertyCount() {
|
||||||
|
var table = CreateDummyTable("T");
|
||||||
|
table.Properties.Add(new PropertyConfig {
|
||||||
|
Identifier = "X", Type = typeof(int), DisplayName = "X", OrderIndex = 0, PropertyType = PropertyType.Numeric
|
||||||
|
});
|
||||||
|
|
||||||
|
var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!;
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeProperty(table, property);
|
||||||
|
|
||||||
|
Assert.Equal(1, config.OrderIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitializeProperty_SetsPropertyType_FromInferPropertyType() {
|
||||||
|
var table = CreateDummyTable("T");
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Number))!;
|
||||||
|
|
||||||
|
var config = ConfigurationHelper.InitializeProperty(table, prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Numeric, config.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenIdentifierNotUnique() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
var global = CreateGlobal(new TableConfig {
|
||||||
|
Identifier = "Test",
|
||||||
|
DisplayName = null,
|
||||||
|
TableType = null,
|
||||||
|
RepositoryType = null,
|
||||||
|
Route = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(global, config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Table identifier 'Test' is not unique", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenTableTypeIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.TableType = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("TableType cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenRepositoryTypeIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.RepositoryType = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("RepositoryType cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenRouteIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.Route = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Route cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenDisplayNameIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.DisplayName = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("DisplayName cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenPropertyIdentifierNotUnique() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.Properties.Add(new PropertyConfig {
|
||||||
|
Identifier = "Prop1",
|
||||||
|
DisplayName = "Duplicate",
|
||||||
|
Type = typeof(int),
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Property identifier 'Prop1' is not unique", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenPropertyDisplayNameIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.Properties[0].DisplayName = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Property 'Prop1': DisplayName cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsError_WhenPropertyTypeIsNull() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
config.Properties[0].Type = null;
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Property 'Prop1': Type cannot be null", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTable_ReturnsNoErrors_WhenConfigIsValid() {
|
||||||
|
var config = CreateValidTable();
|
||||||
|
|
||||||
|
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesNumeric() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Number))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(int), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Numeric, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesNullableNumeric() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.NullableNumber))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(int?), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Numeric | PropertyType.Nullable, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesBoolean() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Flag))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(bool), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Boolean, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesDateTime() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Timestamp))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(DateTime), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.DateTime, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesDateOnly() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Date))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(DateOnly), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.DateOnly, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesTimeOnly() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Time))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(TimeOnly), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.TimeOnly, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesEnum() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.EnumValue))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(TestEnum), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Enum, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesList() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Numbers))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(List<int>), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Numeric | PropertyType.List, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesEnumerable() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Strings))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(IEnumerable<string>), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Text | PropertyType.List, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InferPropertyType_RecognizesEmail() {
|
||||||
|
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Email))!;
|
||||||
|
var result = ConfigurationHelper.InferPropertyType(typeof(string), prop);
|
||||||
|
|
||||||
|
Assert.Equal(PropertyType.Email, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs
Normal file
44
tests/HopFrame.Tests.Core/Helpers/ExpressionHelperTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using HopFrame.Core.Helpers;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Helpers;
|
||||||
|
|
||||||
|
public class ExpressionHelperTests {
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPropertyInfo_ReturnsPropertyInfo_WhenExpressionIsValid() {
|
||||||
|
Expression<Func<TestModel, int>> expr = x => x.Id;
|
||||||
|
|
||||||
|
var prop = ExpressionHelper.GetPropertyInfo(expr);
|
||||||
|
|
||||||
|
Assert.Equal(nameof(TestModel.Id), prop.Name);
|
||||||
|
Assert.Equal(typeof(int), prop.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPropertyInfo_Throws_WhenExpressionRefersToMethod() {
|
||||||
|
Expression<Func<TestModel, int>> expr = x => x.Method();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
|
||||||
|
|
||||||
|
Assert.Contains("refers to a method", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPropertyInfo_Throws_WhenExpressionRefersToField() {
|
||||||
|
Expression<Func<TestModel, int>> expr = x => x.FieldBacking;
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
|
||||||
|
|
||||||
|
Assert.Contains("refers to a field", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPropertyInfo_Throws_WhenExpressionBodyIsNotMemberExpression() {
|
||||||
|
Expression<Func<TestModel, int>> expr = x => 5;
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => ExpressionHelper.GetPropertyInfo(expr));
|
||||||
|
|
||||||
|
Assert.Contains("refers to a method, not a property", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj
Normal file
26
tests/HopFrame.Tests.Core/HopFrame.Tests.Core.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\HopFrame.Core\HopFrame.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Repositories;
|
||||||
|
|
||||||
|
public class HopFrameRepositoryTests {
|
||||||
|
private Mock<HopFrameRepository<TestModel>> CreateMock()
|
||||||
|
=> new(MockBehavior.Strict);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// LoadPageGenericAsync
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadPageGenericAsync_DelegatesToTypedMethod() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
var expected = new List<TestModel> { new TestModel { Id = 1 } };
|
||||||
|
|
||||||
|
mock.Setup(r => r.LoadPageAsync(2, 10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(expected);
|
||||||
|
|
||||||
|
var result = await mock.Object.LoadPageGenericAsync(2, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// SearchGenericAsync
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchGenericAsync_DelegatesToTypedMethod() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
var expected = new List<TestModel> { new TestModel { Id = 5 } };
|
||||||
|
|
||||||
|
mock.Setup(r => r.SearchAsync("abc", 1, 20, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(expected);
|
||||||
|
|
||||||
|
var result = await mock.Object.SearchGenericAsync("abc", 1, 20, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// CreateGenericAsync
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGenericAsync_CastsAndDelegates() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
var model = new TestModel { Id = 99 };
|
||||||
|
|
||||||
|
mock.Setup(r => r.CreateAsync(model, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await mock.Object.CreateGenericAsync(model, CancellationToken.None);
|
||||||
|
|
||||||
|
mock.Verify(r => r.CreateAsync(model, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// UpdateGenericAsync
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateGenericAsync_CastsAndDelegates() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
var model = new TestModel { Id = 77 };
|
||||||
|
|
||||||
|
mock.Setup(r => r.UpdateAsync(model, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await mock.Object.UpdateGenericAsync(model, CancellationToken.None);
|
||||||
|
|
||||||
|
mock.Verify(r => r.UpdateAsync(model, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// DeleteGenericAsync
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteGenericAsync_CastsAndDelegates() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
var model = new TestModel { Id = 42 };
|
||||||
|
|
||||||
|
mock.Setup(r => r.DeleteAsync(model, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await mock.Object.DeleteGenericAsync(model, CancellationToken.None);
|
||||||
|
|
||||||
|
mock.Verify(r => r.DeleteAsync(model, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// CountAsync (direct abstract method)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountAsync_CanBeMockedAndReturnsValue() {
|
||||||
|
var mock = CreateMock();
|
||||||
|
|
||||||
|
mock.Setup(r => r.CountAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(123);
|
||||||
|
|
||||||
|
var result = await mock.Object.CountAsync();
|
||||||
|
|
||||||
|
Assert.Equal(123, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Repositories;
|
||||||
|
using HopFrame.Core.Services.Implementation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace HopFrame.Tests.Core.Services.Implementation;
|
||||||
|
|
||||||
|
public class ConfigAccessorTests {
|
||||||
|
private class TestRepository : IHopFrameRepository {
|
||||||
|
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task<int> CountAsync(CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public Task CreateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteGenericAsync(object entry, CancellationToken ct) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TableConfig CreateTable(string id, string route, Type type)
|
||||||
|
=> new TableConfig {
|
||||||
|
Identifier = id,
|
||||||
|
Route = route,
|
||||||
|
TableType = type,
|
||||||
|
RepositoryType = typeof(TestRepository),
|
||||||
|
DisplayName = id,
|
||||||
|
OrderIndex = 0,
|
||||||
|
Properties = new List<PropertyConfig> {
|
||||||
|
new PropertyConfig {
|
||||||
|
Identifier = "Id",
|
||||||
|
DisplayName = "Id",
|
||||||
|
Type = typeof(int),
|
||||||
|
OrderIndex = 0,
|
||||||
|
PropertyType = PropertyType.Numeric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private HopFrameConfig CreateConfig(params TableConfig[] tables)
|
||||||
|
=> new HopFrameConfig { Tables = new List<TableConfig>(tables) };
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GetTableByIdentifier
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByIdentifier_ReturnsCorrectTable() {
|
||||||
|
var table = CreateTable("A", "a", typeof(TestModel));
|
||||||
|
var config = CreateConfig(table);
|
||||||
|
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByIdentifier("A");
|
||||||
|
|
||||||
|
Assert.Equal(table, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByIdentifier_ReturnsNull_WhenNotFound() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByIdentifier("missing");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GetTableByRoute
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByRoute_ReturnsCorrectTable() {
|
||||||
|
var table = CreateTable("A", "routeA", typeof(TestModel));
|
||||||
|
var config = CreateConfig(table);
|
||||||
|
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByRoute("routeA");
|
||||||
|
|
||||||
|
Assert.Equal(table, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByRoute_ReturnsNull_WhenNotFound() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByRoute("missing");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// GetTableByType
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByType_ReturnsCorrectTable() {
|
||||||
|
var table = CreateTable("A", "a", typeof(TestModel));
|
||||||
|
var config = CreateConfig(table);
|
||||||
|
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByType(typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Equal(table, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTableByType_ReturnsNull_WhenNotFound() {
|
||||||
|
var config = CreateConfig();
|
||||||
|
var accessor = new ConfigAccessor(config, Mock.Of<IServiceProvider>());
|
||||||
|
|
||||||
|
var result = accessor.GetTableByType(typeof(TestModel));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// LoadRepository
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadRepository_ResolvesRepositoryFromServiceProvider() {
|
||||||
|
var table = CreateTable("A", "a", typeof(TestModel));
|
||||||
|
|
||||||
|
var repo = new TestRepository();
|
||||||
|
|
||||||
|
var providerMock = new Mock<IServiceProvider>();
|
||||||
|
providerMock
|
||||||
|
.Setup(p => p.GetService(typeof(TestRepository)))
|
||||||
|
.Returns(repo);
|
||||||
|
|
||||||
|
var accessor = new ConfigAccessor(CreateConfig(table), providerMock.Object);
|
||||||
|
|
||||||
|
var result = accessor.LoadRepository(table);
|
||||||
|
|
||||||
|
Assert.Equal(repo, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadRepository_Throws_WhenServiceNotRegistered() {
|
||||||
|
var table = CreateTable("A", "a", typeof(TestModel));
|
||||||
|
|
||||||
|
var provider = new ServiceCollection().BuildServiceProvider();
|
||||||
|
|
||||||
|
var accessor = new ConfigAccessor(CreateConfig(table), provider);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => accessor.LoadRepository(table));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tests/HopFrame.Tests.Core/TestModel.cs
Normal file
10
tests/HopFrame.Tests.Core/TestModel.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace HopFrame.Tests.Core;
|
||||||
|
|
||||||
|
public class TestModel {
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
public int Method() => 42;
|
||||||
|
|
||||||
|
public int FieldBacking;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user