4 Commits

Author SHA1 Message Date
e9e9fbf5e9 Added sorting
All checks were successful
HopFrame CI / build (push) Successful in 55s
HopFrame CI / test (push) Successful in 1m6s
2026-02-25 21:25:34 +01:00
ff2634ff41 Started working on frontend
All checks were successful
HopFrame CI / build (push) Successful in 44s
HopFrame CI / test (push) Successful in 52s
2026-02-25 16:33:46 +01:00
d2082ef33c Unified docstrings
All checks were successful
HopFrame CI / build (push) Successful in 47s
HopFrame CI / test (push) Successful in 49s
2026-02-23 19:54:35 +01:00
6730d57771 Added ef core integration
All checks were successful
HopFrame CI / build (push) Successful in 46s
HopFrame CI / test (push) Successful in 50s
2026-02-23 16:20:32 +01:00
48 changed files with 1700 additions and 139 deletions

View File

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

View File

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

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

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

View File

@@ -1,4 +1,9 @@
using HopFrame.Core.EFCore;
using HopFrame.Web;
using Microsoft.EntityFrameworkCore;
using TestApplication;
using TestApplication.Components;
using TestApplication.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -6,6 +11,27 @@ var builder = WebApplication.CreateBuilder(args);
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.
@@ -15,6 +41,40 @@ if (!app.Environment.IsDevelopment()) {
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();
@@ -22,6 +82,7 @@ app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
.AddInteractiveServerRenderMode()
.AddHopFrame();
app.Run();

View File

@@ -4,7 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -13,7 +13,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7126;http://localhost:5281",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -9,6 +9,12 @@
<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>

View File

@@ -1,10 +1,10 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for the library
*/
/// <summary>
/// The configuration for the library
/// </summary>
public sealed class HopFrameConfig {
/** The configurations for the table repositories */
/// The configurations for the table repositories
public IList<TableConfig> Tables { get; set; } = new List<TableConfig>();
internal HopFrameConfig() {}

View File

@@ -1,41 +1,93 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for a single property
*/
/// <summary>
/// The configuration for a single property
/// </summary>
public class PropertyConfig {
/** The unique identifier for the property (usually the real property name in the model) */
/// [GENERATED] The unique identifier for the property (usually the real property name in the model)
public required string Identifier { get; init; }
/** The displayed name of the Property */
/// [GENERATED] The displayed name of the Property
public required string DisplayName { get; set; }
/** The type of the property */
/// [GENERATED] The real type of the property
public required Type Type { get; set; }
/** Determines if the property will appear in the table */
/// [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 */
/// 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 */
/// Determines if the table can be searched by the property
public bool Searchable { get; set; } = true;
/**
* Determines if the value of the property can be edited
* (if true the value can still be set during creation)
*/
/// <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 */
/// Determines if the property is visible in the creation or edit dialog
public bool Creatable { get; set; } = true;
/** Determines if the actual value should be displayed (useful for passwords) */
public bool DisplayValue { get; set; } = true;
/** The place (from left to right) that the property will appear in the table and editor */
/// [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
}

View File

@@ -1,32 +1,35 @@
namespace HopFrame.Core.Configuration;
/**
* The configuration for a table
*/
/// <summary>
/// The configuration for a table
/// </summary>
public class TableConfig {
/** The unique identifier for the table (usually the name of the model) */
/// [GENERATED] The unique identifier for the table (usually the name of the model)
public required string Identifier { get; init; }
/** The configurations for the properties of the model */
/// [GENERATED] The configurations for the properties of the model
public IList<PropertyConfig> Properties { get; set; } = new List<PropertyConfig>();
/** The type of the model */
/// [GENERATED] The type of the model
public required Type TableType { get; set; }
/** The type identifier for the repository */
/// [GENERATED] The type identifier for the repository
public required Type RepositoryType { get; set; }
/** the url of the table page */
/// [GENERATED] the url of the table page
public required string Route { get; set; }
/** The displayed name of the table */
/// [GENERATED] The displayed name of the table
public required string DisplayName { get; set; }
/** A short description for the table */
/// A short description for the table
public string? Description { get; set; }
/** The place (from top to bottom) that the table will appear in on the sidebar */
/// [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() {}
}

View File

@@ -6,23 +6,25 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="HopFrameConfig"/>
*/
/// <summary>
/// The configurator for the <see cref="HopFrameConfig"/>
/// </summary>
public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection services) {
/** The internal config that is modified */
/// 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 : notnull {
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));
Services.TryAddScoped(typeof(TRepository));
configurator?.Invoke(new TableConfigurator<TModel>(table));
return this;
}
@@ -34,7 +36,7 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
/// <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 : notnull {
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}'!");
@@ -44,8 +46,25 @@ public class HopFrameConfigurator(HopFrameConfig config, IServiceCollection serv
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);
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;
}
}

View File

@@ -2,58 +2,61 @@
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="PropertyConfig"/>
*/
/// <summary>
/// The configurator for the <see cref="PropertyConfig"/>
/// </summary>
public class PropertyConfigurator(PropertyConfig config) {
/** The internal config that is modified */
/// The internal config that is modified
public PropertyConfig Config { get; } = config;
/** <inheritdoc cref="PropertyConfig.DisplayName" /> */
/// <inheritdoc cref="PropertyConfig.DisplayName" />
public PropertyConfigurator SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="PropertyConfig.Listable" /> */
/// <inheritdoc cref="PropertyConfig.Listable" />
public PropertyConfigurator Listable(bool listable) {
Config.Listable = listable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Sortable" /> */
/// <inheritdoc cref="PropertyConfig.Sortable" />
public PropertyConfigurator Sortable(bool sortable) {
Config.Sortable = sortable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Searchable" /> */
/// <inheritdoc cref="PropertyConfig.Searchable" />
public PropertyConfigurator Searchable(bool searchable) {
Config.Searchable = searchable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Editable" /> */
/// <inheritdoc cref="PropertyConfig.Editable" />
public PropertyConfigurator Editable(bool editable) {
Config.Editable = editable;
return this;
}
/** <inheritdoc cref="PropertyConfig.Creatable" /> */
/// <inheritdoc cref="PropertyConfig.Creatable" />
public PropertyConfigurator Creatable(bool creatable) {
Config.Creatable = creatable;
return this;
}
/** <inheritdoc cref="PropertyConfig.DisplayValue" /> */
public PropertyConfigurator DisplayValue(bool displayValue) {
Config.DisplayValue = displayValue;
return this;
}
/** <inheritdoc cref="PropertyConfig.OrderIndex" /> */
/// <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;
}
}

View File

@@ -4,38 +4,38 @@ using HopFrame.Core.Helpers;
namespace HopFrame.Core.Configurators;
/**
* The configurator for the <see cref="TableConfig"/>
*/
public class TableConfigurator<TModel>(TableConfig config) where TModel : notnull {
/** The internal config that is modified */
/// <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"/> */
/// <inheritdoc cref="TableConfig.Route"/>
public TableConfigurator<TModel> SetRoute(string route) {
Config.Route = route;
return this;
}
/** <inheritdoc cref="TableConfig.DisplayName"/> */
/// <inheritdoc cref="TableConfig.DisplayName"/>
public TableConfigurator<TModel> SetDisplayName(string displayName) {
Config.DisplayName = displayName;
return this;
}
/** <inheritdoc cref="TableConfig.Description"/> */
/// <inheritdoc cref="TableConfig.Description"/>
public TableConfigurator<TModel> SetDescription(string description) {
Config.Description = description;
return this;
}
/** <inheritdoc cref="TableConfig.OrderIndex"/> */
/// <inheritdoc cref="TableConfig.OrderIndex"/>
public TableConfigurator<TModel> SetOrderIndex(int index) {
Config.OrderIndex = index;
return this;
}
/** Returns the configurator for a property */
/// Returns the configurator for a property
public PropertyConfigurator Property(string identifier) {
var prop = Config.Properties
.FirstOrDefault(p => p.Identifier == identifier);
@@ -46,8 +46,8 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : notnul
return new PropertyConfigurator(prop);
}
/** <inheritdoc cref="Property"/> */
public PropertyConfigurator Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
/// <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);
@@ -56,4 +56,16 @@ public class TableConfigurator<TModel>(TableConfig config) where TModel : notnul
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;
}
}

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

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

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

View File

@@ -1,4 +1,7 @@
using System.Reflection;
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Runtime.CompilerServices;
using HopFrame.Core.Configuration;
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
@@ -16,8 +19,8 @@ internal static class ConfigurationHelper {
RepositoryType = repositoryType,
TableType = modelType,
Identifier = identifier,
Route = modelType.Name.ToLower(),
DisplayName = modelType.Name,
Route = modelType.Name.ToLower() + 's',
DisplayName = modelType.Name + 's',
OrderIndex = global.Tables.Count
};
@@ -28,7 +31,7 @@ internal static class ConfigurationHelper {
return config;
}
private static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
public static PropertyConfig InitializeProperty(TableConfig table, PropertyInfo property) {
var identifier = property.Name;
if (table.Properties.Any(p => p.Identifier == identifier))
@@ -38,12 +41,64 @@ internal static class ConfigurationHelper {
Identifier = identifier,
Type = property.PropertyType,
DisplayName = property.Name,
OrderIndex = table.Properties.Count
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";

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

View File

@@ -12,6 +12,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
</ItemGroup>

View File

@@ -2,40 +2,48 @@
namespace HopFrame.Core.Repositories;
/** The base repository that provides access to the model dataset */
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : notnull {
/// The base repository that provides access to the model dataset
public abstract class HopFrameRepository<TModel> : IHopFrameRepository where TModel : class {
/** <inheritdoc cref="LoadPageGenericAsync"/> */
/// <inheritdoc cref="LoadPageGenericAsync"/>
public abstract Task<IEnumerable<TModel>> LoadPageAsync(int page, int perPage, CancellationToken ct = default);
/** <inheritdoc/> */
/// <inheritdoc/>
public abstract Task<int> CountAsync(CancellationToken ct = default);
/** <inheritdoc cref="SearchGenericAsync"/> */
/// <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);
/// <inheritdoc cref="CreateGenericAsync"/>
public abstract Task CreateAsync(TModel entry, CancellationToken ct = default);
/** <inheritdoc cref="DeleteGenericAsync"/> */
public abstract Task DeleteAsync(TModel entry, CancellationToken ct);
/// <inheritdoc cref="UpdateGenericAsync"/>
public abstract Task UpdateAsync(TModel entry, CancellationToken ct = default);
/** <inheritdoc/> */
public async Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, 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> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
/// <inheritdoc/>
public async Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct) {
return await SearchAsync(searchTerm, page, perPage, ct);
}
/** <inheritdoc/> */
/// <inheritdoc/>
public Task CreateGenericAsync(object entry, CancellationToken ct) {
return CreateAsync((TModel)entry, ct);
}
/** <inheritdoc/> */
/// <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);
}

View File

@@ -1,9 +1,7 @@
using System.Collections;
#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
#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 */
/// The generic repository that provides access to the model dataset
public interface IHopFrameRepository {
/// <summary>
@@ -11,12 +9,12 @@ public interface IHopFrameRepository {
/// </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> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default);
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 = default);
public Task<int> CountAsync(CancellationToken ct);
/// <summary>
/// Searches through the whole dataset and returns a page of matching entries
@@ -24,7 +22,7 @@ public interface IHopFrameRepository {
/// <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> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default);
public Task<IEnumerable<object>> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct);
/// <summary>
@@ -33,6 +31,12 @@ public interface IHopFrameRepository {
/// <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>

View File

@@ -6,17 +6,19 @@ using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core;
/** An extension class to provide access to the setup of the library */
/// 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 void AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
/// 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;
}
}

View File

@@ -3,7 +3,7 @@ using HopFrame.Core.Repositories;
namespace HopFrame.Core.Services;
/** A service used to access configs and repositories provided by the <see cref="HopFrameConfig"/> */
/// A service used to access configs and repositories provided by the <see cref="HopFrameConfig"/>
public interface IConfigAccessor {
/// <summary>

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

View 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!;
}
}

View File

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

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace HopFrame.Web.Components;
public class CancellableComponent : ComponentBase, IDisposable {
protected CancellationTokenSource TokenSource { get; } = new();
public void Dispose() {
TokenSource.Dispose();
}
}

View File

@@ -0,0 +1,33 @@
<MudCard Style="width: 350px; height: 200px">
<MudCardHeader>
<CardHeaderAvatar>
<MudIcon Icon="@Icon" Style="margin: auto" />
</CardHeaderAvatar>
<CardHeaderContent>
<MudText Typo="Typo.h6">@Title</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>@Description</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Href="@Href">Open</MudButton>
</MudCardActions>
</MudCard>
@code {
[Parameter]
public required string Title { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public string? Description { get; set; }
[Parameter]
public required string Href { get; set; }
[Parameter]
public required string Icon { get; set; }
}

View File

@@ -0,0 +1,40 @@
@using HopFrame.Core.Configuration
<MudDrawer Open="true" Fixed="true" ClipMode="DrawerClipMode.Docked" Width="200px">
<MudNavMenu>
<MudNavLink Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.SpaceDashboard" Href="admin">Dashboard</MudNavLink>
<br>
@foreach (var route in Routes) {
<MudNavLink Match="NavLinkMatch.All" Icon="@route.Icon" Href="@route.Route">@route.Name</MudNavLink>
}
</MudNavMenu>
</MudDrawer>
@inject HopFrameConfig Config
@code {
private readonly struct RouteDefinition {
public required string Route { get; init; }
public required string Icon { get; init; }
public required string Name { get; init; }
}
private RouteDefinition[] Routes { get; set; } = null!;
protected override void OnInitialized() {
base.OnInitialized();
Routes = Config.Tables
.OrderBy(t => t.OrderIndex)
.Select(table => new RouteDefinition {
Route = "admin/" + table.Route,
Icon = Icons.Material.Filled.TableChart,
Name = table.DisplayName
})
.ToArray();
}
}

View File

@@ -0,0 +1,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>

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
@page "/admin"
@using HopFrame.Core.Configuration
@using HopFrame.Web.Components.Components
@layout HopFrameLayout
<PageTitle>HopFrame</PageTitle>
<section style="padding: 1.5rem">
<MudText Typo="Typo.h5">Pages</MudText>
<br>
<MudStack Wrap="Wrap.Wrap" Row="true">
@foreach (var route in Routes) {
<Card
Title="@route.Name"
Href="@route.Route"
Icon="@route.Icon"
Description="@route.Description"/>
}
</MudStack>
</section>
@inject HopFrameConfig Config
@code {
private readonly struct RouteDefinition {
public required string Route { get; init; }
public required string Icon { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
}
private RouteDefinition[] Routes { get; set; } = null!;
protected override void OnInitialized() {
base.OnInitialized();
Routes = Config.Tables
.OrderBy(t => t.OrderIndex)
.Select(table => new RouteDefinition {
Route = "admin/" + table.Route,
Icon = Icons.Material.Filled.TableChart,
Name = table.DisplayName,
Description = table.Description,
})
.ToArray();
}
}

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,27 @@
using HopFrame.Core.Configuration;
using HopFrame.Core.Services;
using Microsoft.AspNetCore.Components;
namespace HopFrame.Web.Components.Pages;
public partial class TablePage(IConfigAccessor accessor, NavigationManager navigator) : 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;
}
}

View File

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

View File

@@ -0,0 +1,43 @@
using HopFrame.Core;
using HopFrame.Core.Configurators;
using HopFrame.Web.Components;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace HopFrame.Web;
/// An extension class to provide access to the setup of the library
public static class ServiceCollectionExtensions {
/// Configures the library using the provided configurator
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator) {
services.AddHopFrameServices(configurator);
services.AddMudServices();
return services;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static RazorComponentsEndpointConventionBuilder AddHopFrame(this RazorComponentsEndpointConventionBuilder builder) {
builder
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ServiceCollectionExtensions).Assembly);
return builder;
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static WebApplication MapHopFrame(this WebApplication app) {
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
return app;
}
}

View File

@@ -0,0 +1,8 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using MudBlazor

View File

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

View File

@@ -1,5 +1,4 @@
using System.Collections;
using HopFrame.Core.Configuration;
using HopFrame.Core.Configuration;
using HopFrame.Core.Configurators;
using HopFrame.Core.Repositories;
using Microsoft.Extensions.DependencyInjection;
@@ -8,18 +7,23 @@ namespace HopFrame.Tests.Core.Configurators;
public class HopFrameConfiguratorTests {
private class TestRepository : IHopFrameRepository {
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct = default) {
public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException();
}
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
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();
}
@@ -41,7 +45,8 @@ public class HopFrameConfiguratorTests {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0
OrderIndex = 0,
PropertyType = PropertyType.Numeric
}
}
};

View File

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

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

View File

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

View File

@@ -1,4 +1,4 @@
using System.Reflection;
using System.ComponentModel.DataAnnotations;
using HopFrame.Core.Configuration;
using HopFrame.Core.Helpers;
@@ -7,6 +7,28 @@ using HopFrame.Core.Helpers;
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() };
@@ -21,7 +43,8 @@ public class ConfigurationHelperTests {
new PropertyConfig {
Identifier = "Prop1",
DisplayName = "Property 1",
Type = typeof(int)
Type = typeof(int),
PropertyType = PropertyType.Numeric
}
}
};
@@ -64,8 +87,8 @@ public class ConfigurationHelperTests {
Assert.Equal(typeof(string), config.RepositoryType);
Assert.Equal(typeof(TestModel), config.TableType);
Assert.Equal("testmodel", config.Route);
Assert.Equal("TestModel", config.DisplayName);
Assert.Equal("testmodels", config.Route);
Assert.Equal("TestModels", config.DisplayName);
Assert.Equal(0, config.OrderIndex);
}
@@ -92,12 +115,30 @@ public class ConfigurationHelperTests {
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 = InvokeInitializeProperty(table, property);
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal("Id", config.Identifier);
}
@@ -105,12 +146,14 @@ public class ConfigurationHelperTests {
[Fact]
public void InitializeProperty_GeneratesGuid_WhenIdentifierAlreadyExists() {
var table = CreateDummyTable("T");
table.Properties.Add(new PropertyConfig
{ Identifier = "Id", Type = typeof(int), DisplayName = "Id", OrderIndex = 0 });
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 = InvokeInitializeProperty(table, property);
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.NotEqual("Id", config.Identifier);
Assert.True(Guid.TryParse(config.Identifier, out _));
@@ -121,7 +164,7 @@ public class ConfigurationHelperTests {
var table = CreateDummyTable("T");
var property = typeof(TestModel).GetProperty(nameof(TestModel.Name))!;
var config = InvokeInitializeProperty(table, property);
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal("Name", config.DisplayName);
Assert.Equal(typeof(string), config.Type);
@@ -131,23 +174,28 @@ public class ConfigurationHelperTests {
[Fact]
public void InitializeProperty_SetsOrderIndex_ToCurrentPropertyCount() {
var table = CreateDummyTable("T");
table.Properties.Add(new PropertyConfig
{ Identifier = "X", Type = typeof(int), DisplayName = "X", OrderIndex = 0 });
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 = InvokeInitializeProperty(table, property);
var config = ConfigurationHelper.InitializeProperty(table, property);
Assert.Equal(1, config.OrderIndex);
}
private PropertyConfig InvokeInitializeProperty(TableConfig table, PropertyInfo property) {
var method = typeof(ConfigurationHelper)
.GetMethod("InitializeProperty", BindingFlags.NonPublic | BindingFlags.Static)!;
[Fact]
public void InitializeProperty_SetsPropertyType_FromInferPropertyType() {
var table = CreateDummyTable("T");
var prop = typeof(PropertyTypeModel).GetProperty(nameof(PropertyTypeModel.Number))!;
return (PropertyConfig)method.Invoke(null, [table, property])!;
var config = ConfigurationHelper.InitializeProperty(table, prop);
Assert.Equal(PropertyType.Numeric, config.PropertyType);
}
[Fact]
public void ValidateTable_ReturnsError_WhenIdentifierNotUnique() {
var config = CreateValidTable();
@@ -210,7 +258,8 @@ public class ConfigurationHelperTests {
config.Properties.Add(new PropertyConfig {
Identifier = "Prop1",
DisplayName = "Duplicate",
Type = typeof(int)
Type = typeof(int),
PropertyType = PropertyType.Numeric
});
var result = ConfigurationHelper.ValidateTable(CreateGlobal(), config).ToList();
@@ -246,4 +295,84 @@ public class ConfigurationHelperTests {
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);
}
}

View File

@@ -4,7 +4,6 @@ using Moq;
namespace HopFrame.Tests.Core.Repositories;
public class HopFrameRepositoryTests {
private Mock<HopFrameRepository<TestModel>> CreateMock()
=> new(MockBehavior.Strict);
@@ -21,7 +20,7 @@ public class HopFrameRepositoryTests {
mock.Setup(r => r.LoadPageAsync(2, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var result = await mock.Object.LoadPageGenericAsync(2, 10);
var result = await mock.Object.LoadPageGenericAsync(2, 10, CancellationToken.None);
Assert.Equal(expected, result);
}
@@ -39,7 +38,7 @@ public class HopFrameRepositoryTests {
mock.Setup(r => r.SearchAsync("abc", 1, 20, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var result = await mock.Object.SearchGenericAsync("abc", 1, 20);
var result = await mock.Object.SearchGenericAsync("abc", 1, 20, CancellationToken.None);
Assert.Equal(expected, result);
}
@@ -62,6 +61,24 @@ public class HopFrameRepositoryTests {
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
// -------------------------------------------------------------

View File

@@ -1,5 +1,4 @@
using System.Collections;
using HopFrame.Core.Configuration;
using HopFrame.Core.Configuration;
using HopFrame.Core.Repositories;
using HopFrame.Core.Services.Implementation;
using Microsoft.Extensions.DependencyInjection;
@@ -9,18 +8,23 @@ namespace HopFrame.Tests.Core.Services.Implementation;
public class ConfigAccessorTests {
private class TestRepository : IHopFrameRepository {
public Task<IEnumerable> LoadPageGenericAsync(int page, int perPage, CancellationToken ct = default) {
public Task<IEnumerable<object>> LoadPageGenericAsync(int page, int perPage, CancellationToken ct) {
throw new NotImplementedException();
}
public Task<int> CountAsync(CancellationToken ct = default) {
public Task<int> CountAsync(CancellationToken ct) {
throw new NotImplementedException();
}
public Task<IEnumerable> SearchGenericAsync(string searchTerm, int page, int perPage, CancellationToken ct = default) {
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();
}
@@ -39,7 +43,8 @@ public class ConfigAccessorTests {
Identifier = "Id",
DisplayName = "Id",
Type = typeof(int),
OrderIndex = 0
OrderIndex = 0,
PropertyType = PropertyType.Numeric
}
}
};

View File

@@ -2,7 +2,7 @@
public class TestModel {
public int Id { get; set; }
public string Name { get; set; }
public string Name { get; set; } = null!;
public int Method() => 42;