Added support for custom repositories

This commit is contained in:
2025-03-14 21:46:41 +01:00
parent 4407d173a9
commit 222d4276d2
18 changed files with 373 additions and 58 deletions

View File

@@ -11,9 +11,25 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="Fixed directory in pipeline">
<change beforePath="$PROJECT_DIR$/.gitlab-ci.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.gitlab-ci.yml" afterDir="false" />
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/RepositoryGroupConfig.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Repositories/IHopFrameRepository.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/RepositoryTableManager.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/Guest.cs" afterDir="false" />
<change afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Models/Message.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/HopFrameConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/HopFrameConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/ITableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/ITableManager.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/TableManager.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Internal/ExporterPlugin.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/TableManagerTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Core/Services/TableManagerTests.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tests/HopFrame.Tests.Web/Components/Pages/HopFrameTablePageTests.cs" beforeDir="false" afterPath="$PROJECT_DIR$/tests/HopFrame.Tests.Web/Components/Pages/HopFrameTablePageTests.cs" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -33,7 +49,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feature/exporters" />
<entry key="$PROJECT_DIR$" value="dev" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -62,6 +78,7 @@
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/a0/0a968c53/IEnumerable`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/ad/ba9a50e7/ICollection.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/fc/6f7933d2/ICollection`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/0f73968de5cdfe0aa57817b8dd2a3c5d1db615ba4ae4629a5af59bb6c8922/RemoteNavigationManager.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/10c66a9a1e137111895f7182a2ae246eabe06a261578c3fa495a45f6f177d35/IconVariant.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669/List.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/24dd1164ba47541cb1d3eb011e638e16953dbea3ae3f4dc208c3bbf3e96298a/ServiceCollectionServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" />
@@ -79,6 +96,7 @@
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/6d1d64f05e7045295fa180276a8c2aef0302c9e96eb53b3431ab13db4579/FluentAppBarItem.razor.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/6fe785cceb29ca2d1da78e157315815a7c4372b582a20a71c28b210f9d56e/IconsExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7ad7d2d0ae865063993eb8a03427815ea3bdb6a774e0a2f95512e9f669a4f489/MemberEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/876cd892fc66a9dc8f6afd3704c264acebdfc46aed08089463e8117c21a532/String.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/87c584767b46b5fd42769be76547105558e6690f785614efddca134b2d682/Type.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/8d5d6cbff46ddc7b152381f92ae1ae51d3e7b57b14dd23840a11f5aaaaed396/InternalEntityEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a882d183338544fdbcbdfc7b6d3dcb78916630765551644a221b5be9c45a121b/Int32.cs" root0="FORCE_HIGHLIGHTING" />
@@ -118,28 +136,28 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.HopFrame.Testing.Api: https.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;,
&quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;,
&quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;,
&quot;git-widget-placeholder&quot;: &quot;dev&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
".NET Project.HopFrame.Testing.executor": "Run",
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
"git-widget-placeholder": "!34 on feature/repositories",
"list.type.of.created.stylesheet": "CSS",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}</component>
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
@@ -258,7 +276,8 @@
<workItem from="1740738257628" duration="3216000" />
<workItem from="1740741585276" duration="17000" />
<workItem from="1740742098571" duration="78000" />
<workItem from="1740742471317" duration="413000" />
<workItem from="1740742471317" duration="672000" />
<workItem from="1741974241977" duration="10362000" />
</task>
<task id="LOCAL-00001" summary="Added basic configuration">
<option name="closed" value="true" />
@@ -644,7 +663,15 @@
<option name="project" value="LOCAL" />
<updated>1740742749325</updated>
</task>
<option name="localTasksCounter" value="49" />
<task id="LOCAL-00049" summary="Reverted pipeline to include all jobs">
<option name="closed" value="true" />
<created>1740743139064</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1740743139064</updated>
</task>
<option name="localTasksCounter" value="50" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -695,7 +722,6 @@
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Added a simple web api abstraction method" />
<MESSAGE value="Implemented async delegates" />
<MESSAGE value="Added maximum display length" />
<MESSAGE value="Fixed test for table view" />
@@ -720,6 +746,7 @@
<MESSAGE value="Prepared CI for v3.2.0" />
<MESSAGE value="Removed unused dependency" />
<MESSAGE value="Fixed directory in pipeline" />
<option name="LAST_COMMIT_MESSAGE" value="Fixed directory in pipeline" />
<MESSAGE value="Reverted pipeline to include all jobs" />
<option name="LAST_COMMIT_MESSAGE" value="Reverted pipeline to include all jobs" />
</component>
</project>

View File

@@ -2,7 +2,13 @@
namespace HopFrame.Core.Config;
public class DbContextConfig {
public interface ITableGroupConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; }
public HopFrameConfig ParentConfig { get; }
}
public class DbContextConfig : ITableGroupConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; } = new();
public HopFrameConfig ParentConfig { get; }

View File

@@ -1,11 +1,13 @@
using HopFrame.Core.Callbacks;
using System.Linq.Expressions;
using HopFrame.Core.Callbacks;
using HopFrame.Core.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core.Config;
public class HopFrameConfig {
public List<DbContextConfig> Contexts { get; } = new();
public List<ITableGroupConfig> Contexts { get; } = new();
public bool DisplayUserInfo { get; set; } = true;
public string? BasePolicy { get; set; }
public string? LoginPageRewrite { get; set; }
@@ -48,6 +50,36 @@ public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollecti
return new DbContextConfigurator<TDbContext>(context);
}
/// <summary>
/// Adds a table of the desired type and configures it to use a custom repository
/// </summary>
/// <param name="keyExpression">The key of the model</param>
/// <param name="configurator">The configurator used for configuring the table page</param>
/// <typeparam name="TRepository">The repository class that inherits from the <see cref="IHopFrameRepository{TModel,TKey}"/> (needs to be registered as a service)</typeparam>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <typeparam name="TKey">The type of the primary key</typeparam>
public HopFrameConfigurator AddCustomRepository<TRepository, TModel, TKey>(Expression<Func<TModel, TKey>> keyExpression, Action<TableConfigurator<TModel>> configurator) {
var context = AddCustomRepository<TRepository, TModel, TKey>(keyExpression);
configurator.Invoke(context);
return this;
}
/// <summary>
/// Adds a table of the desired type and configures it to use a custom repository
/// </summary>
/// <param name="keyExpression">The key of the model</param>
/// <typeparam name="TRepository">The repository class that inherits from the <see cref="IHopFrameRepository{TModel,TKey}"/> (needs to be registered as a service)</typeparam>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <typeparam name="TKey">The type of the primary key</typeparam>
/// <returns>The configurator used for configuring the table page</returns>
public TableConfigurator<TModel> AddCustomRepository<TRepository, TModel, TKey>(Expression<Func<TModel, TKey>> keyExpression) {
var keyProperty = TableConfigurator<TModel>.GetPropertyInfo(keyExpression);
var context = new RepositoryGroupConfig(typeof(TRepository), keyProperty, InnerConfig);
context.Tables.Add(new TableConfig(context, typeof(TModel), typeof(TRepository).Name, 0));
InnerConfig.Contexts.Add(context);
return new TableConfigurator<TModel>(context.Tables[0]);
}
/// <summary>
/// Check if a context is already registered in the HopFrame
/// </summary>
@@ -64,6 +96,7 @@ public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollecti
/// <returns>The configurator of the context if it already was defined, null if not</returns>
public DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext : DbContext {
var config = InnerConfig.Contexts
.OfType<DbContextConfig>()
.SingleOrDefault(context => context.ContextType == typeof(TDbContext));
if (config is null) return null;

View File

@@ -0,0 +1,13 @@
using System.Reflection;
namespace HopFrame.Core.Config;
public class RepositoryGroupConfig(Type repoType, PropertyInfo keyProperty, HopFrameConfig config) : ITableGroupConfig {
public Type ContextType { get; } = repoType;
public List<TableConfig> Tables { get; } = new();
public HopFrameConfig ParentConfig { get; } = config;
public PropertyInfo KeyProperty { get; } = keyProperty;
}

View File

@@ -11,7 +11,7 @@ public class TableConfig {
public string PropertyName { get; }
public string DisplayName { get; set; }
public string? Description { get; set; }
public DbContextConfig ContextConfig { get; }
public ITableGroupConfig ContextConfig { get; }
public bool Ignored { get; set; }
public int Order { get; set; }
internal bool Seeded { get; set; }
@@ -23,7 +23,7 @@ public class TableConfig {
public List<PropertyConfig> Properties { get; } = new();
public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) {
public TableConfig(ITableGroupConfig config, Type tableType, string propertyName, int nthTable) {
TableType = tableType;
PropertyName = propertyName;
ContextConfig = config;

View File

@@ -0,0 +1,24 @@
namespace HopFrame.Core.Repositories;
public interface IHopFrameRepository<TModel, in TKey> where TModel : class {
Task<IEnumerable<TModel>> LoadPage(int page, int perPage);
Task<SearchResult<TModel>> Search(string searchTerm, int page, int perPage);
Task<int> GetTotalPageCount(int perPage);
Task CreateItem(TModel item);
Task EditItem(TModel item);
Task DeleteItem(TModel item);
Task<TModel?> GetOne(TKey key);
}
public readonly struct SearchResult<TModel>(IEnumerable<TModel> items, int pageCount) {
public IEnumerable<TModel> Items { get; init; } = items;
public int PageCount { get; init; } = pageCount;
}

View File

@@ -4,7 +4,7 @@ using HopFrame.Core.Config;
namespace HopFrame.Core.Services;
public interface ITableManager {
public IQueryable<object> LoadPage(int page, int perPage = 20);
public Task<IEnumerable<object>> LoadPage(int page, int perPage = 20);
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20);
public Task<int> TotalPages(int perPage = 20);
public Task DeleteItem(object item);

View File

@@ -45,11 +45,18 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName);
if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext;
if (dbContext is null) return null;
var repo = provider.GetService(context.ContextType);
if (repo is null) return null;
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
}
}
return null;
@@ -60,11 +67,18 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.TableType == tableType);
if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext;
if (dbContext is null) return null;
var repo = provider.GetService(context.ContextType);
if (repo is null) return null;
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
}
}
return null;
@@ -72,6 +86,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
private void SeedTableData(TableConfig table) {
if (table.Seeded) return;
if (table.ContextConfig is not DbContextConfig) return;
var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!;
var entity = dbContext.Model.FindEntityType(table.TableType)!;

View File

@@ -0,0 +1,40 @@
using HopFrame.Core.Config;
using HopFrame.Core.Repositories;
namespace HopFrame.Core.Services.Implementations;
public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TKey> repo, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
return await repo.LoadPage(page, perPage);
}
public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
var result = await repo.Search(searchTerm, page, perPage);
return (result.Items, result.PageCount);
}
public Task<int> TotalPages(int perPage = 20) {
return repo.GetTotalPageCount(perPage);
}
public Task DeleteItem(object item) {
return repo.DeleteItem((TModel)item);
}
public Task EditItem(object item) {
return repo.EditItem((TModel)item);
}
public Task AddItem(object item) {
return repo.CreateItem((TModel)item);
}
public Task AddAll(IEnumerable<object> items) {
var tasks = items
.Select(item => repo.CreateItem((TModel)item))
.ToList();
return Task.WhenAll(tasks);
}
public async Task<object?> GetOne(object key) {
return await repo.GetOne((TKey)key);
}
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
var manager = new TableManager<TModel>(null!, null!, explorer, provider);
return await manager.DisplayProperty(item, prop, value, enumerableValue);
}
}

View File

@@ -8,12 +8,13 @@ namespace HopFrame.Core.Services.Implementations;
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
public IQueryable<object> LoadPage(int page, int perPage = 20) {
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
var table = context.Set<TModel>();
var data = IncludeForeignKeys(table);
return data
return await data
.Skip(page * perPage)
.Take(perPage);
.Take(perPage)
.ToArrayAsync();
}
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {

View File

@@ -340,7 +340,7 @@
var relationType = config.Info.PropertyType;
if (config.IsEnumerable) {
relationType = config.Info.PropertyType.GetGenericArguments().First();
relationType = relationType.GetGenericArguments().First();
}
var relationTable = Explorer.GetTable(relationType);

View File

@@ -231,7 +231,7 @@
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
CurrentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync();
CurrentlyDisplayedModels = (await _manager!.LoadPage(_currentPage, PerPage)).ToArray();
_totalPages = await _manager.TotalPages(PerPage);
}

View File

@@ -17,6 +17,8 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
[EventHandler]
public void OnInit(TableInitializedEvent e) {
if (e.Sender.DialogData is not null) return;
e.AddPageButton("Export", () => Export(e.Table), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowUpload());
e.AddPageButton("Import", () => Import(e.Table, e.Sender), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowDownload());
}
@@ -29,8 +31,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
}
var data = await manager
.LoadPage(0, int.MaxValue)
.ToArrayAsync();
.LoadPage(0, int.MaxValue);
var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray();
@@ -81,6 +82,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
if (property is null) continue;
object? value = rowValues[i];
if (string.IsNullOrWhiteSpace((string)value)) continue;
if (property.IsEnumerable) {
if (!property.Info.PropertyType.IsGenericType) continue;
@@ -102,7 +104,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
var enumerable = Activator.CreateInstance(property.Info.PropertyType);
foreach (var key in values) {
var entry = await relationManager.GetOne(ParseString(key, primaryKeyType));
var entry = await relationManager.GetOne(ParseString(key, primaryKeyType)!);
if (entry is null) continue;
addMethod.Invoke(enumerable, [entry]);
@@ -116,7 +118,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
var relationManager = explorer.GetTableManager(property.Info.PropertyType);
var relationPrimaryKeyType = GetPrimaryKeyType(property.Info.PropertyType);
if (relationManager is null || relationPrimaryKeyType is null) continue;
value = await relationManager.GetOne(ParseString((string)value, relationPrimaryKeyType));
value = await relationManager.GetOne(ParseString((string)value, relationPrimaryKeyType)!);
}
else if (property.Info.PropertyType == typeof(Guid)) {
var success = Guid.TryParse((string)value, out var guid);
@@ -151,13 +153,20 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
if (property.IsEnumerable) {
var enumerable = (IEnumerable)value;
return '[' + string.Join(',', enumerable.OfType<object>().Select(o => SelectPrimaryKey(o) ?? o.ToString())) + ']';
return '[' + string.Join(',', enumerable.OfType<object>().Select(o => SelectPrimaryKey(o, property) ?? o.ToString())) + ']';
}
return SelectPrimaryKey(value) ?? value.ToString() ?? string.Empty;
return SelectPrimaryKey(value, property) ?? value.ToString() ?? string.Empty;
}
private string? SelectPrimaryKey(object entity) {
private string? SelectPrimaryKey(object entity, PropertyConfig config) {
if (config.IsRelation) {
var table = explorer.GetTable(entity.GetType());
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.GetValue(entity)?.ToString();
}
}
return entity
.GetType()
.GetProperties()
@@ -169,6 +178,11 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
}
private Type? GetPrimaryKeyType(Type tableType) {
var table = explorer.GetTable(tableType);
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.PropertyType;
}
return tableType
.GetProperties()
.FirstOrDefault(prop => prop

View File

@@ -0,0 +1,59 @@
using HopFrame.Core.Repositories;
namespace HopFrame.Testing.Models;
public class Guest {
public int Id { get; set; }
public string Name { get; set; }
public List<Message> Messages { get; set; } = new();
}
public class GuestRepository : IHopFrameRepository<Guest, int> {
public List<Guest> Guests { get; } = new();
public async Task<IEnumerable<Guest>> LoadPage(int page, int perPage) {
return Guests
.Skip(page * perPage)
.Take(perPage);
}
public async Task<SearchResult<Guest>> Search(string searchTerm, int page, int perPage) {
var results = Guests
.Where(message => message.Name.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase))
.ToArray();
var totalPages = (int)Math.Ceiling(results.Length / (double)perPage);
return new SearchResult<Guest>(results
.Skip(page * perPage)
.Take(perPage), totalPages);
}
public async Task<int> GetTotalPageCount(int perPage) {
return (int)Math.Ceiling(Guests.Count / (double)perPage);
}
public Task CreateItem(Guest item) {
Guests.Add(item);
return Task.CompletedTask;
}
public Task EditItem(Guest item) {
var old = Guests.Find(m => m.Id == item.Id);
if (old is not null)
Guests.Remove(old);
Guests.Add(item);
return Task.CompletedTask;
}
public Task DeleteItem(Guest item) {
Guests.Remove(item);
return Task.CompletedTask;
}
public async Task<Guest?> GetOne(int key) {
return Guests.Find(m => m.Id == key);
}
}

View File

@@ -0,0 +1,59 @@
using HopFrame.Core.Repositories;
namespace HopFrame.Testing.Models;
public class Message {
public required int MessageIdentifier { get; set; }
public required User Sender { get; set; }
public required Guest Receiver { get; set; }
public required string Content { get; set; }
}
public class MessageRepository : IHopFrameRepository<Message, int> {
public List<Message> Messages { get; } = new();
public async Task<IEnumerable<Message>> LoadPage(int page, int perPage) {
return Messages
.Skip(page * perPage)
.Take(perPage);
}
public async Task<SearchResult<Message>> Search(string searchTerm, int page, int perPage) {
var results = Messages
.Where(message => message.Content.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase))
.ToArray();
var totalPages = (int)Math.Ceiling(results.Length / (double)perPage);
return new SearchResult<Message>(results
.Skip(page * perPage)
.Take(perPage), totalPages);
}
public async Task<int> GetTotalPageCount(int perPage) {
return (int)Math.Ceiling(Messages.Count / (double)perPage);
}
public Task CreateItem(Message item) {
Messages.Add(item);
return Task.CompletedTask;
}
public Task EditItem(Message item) {
var old = Messages.Find(m => m.MessageIdentifier == item.MessageIdentifier);
if (old is not null)
Messages.Remove(old);
Messages.Add(item);
return Task.CompletedTask;
}
public Task DeleteItem(Message item) {
Messages.Remove(item);
return Task.CompletedTask;
}
public async Task<Message?> GetOne(int key) {
return Messages.Find(m => m.MessageIdentifier == key);
}
}

View File

@@ -4,6 +4,7 @@ using HopFrame.Testing.Components;
using HopFrame.Testing.Models;
using HopFrame.Web;
using Microsoft.EntityFrameworkCore;
using Message = HopFrame.Testing.Models.Message;
var builder = WebApplication.CreateBuilder(args);
@@ -88,8 +89,31 @@ builder.Services.AddHopFrame(options => {
.SetPolicy("counter.view");
options.AddExporters();
options.AddCustomRepository<GuestRepository, Guest, int>(g => g.Id, table => {
table.SetDisplayName("Guests");
table.Property(g => g.Messages)
.ForceRelation(true)
.FormatEach<Message>((m, _) => m.Content);
});
options.AddCustomRepository<MessageRepository, Message, int>(m => m.MessageIdentifier, table => {
table.SetDisplayName("Messages");
table.Property(m => m.Receiver)
.ForceRelation()
.Format((u, _) => u.Name);
table.Property(m => m.Sender)
.ForceRelation()
.Format((u, _) => u.Username ?? string.Empty);
});
});
builder.Services.AddSingleton<MessageRepository>();
builder.Services.AddSingleton<GuestRepository>();
var app = builder.Build();
// Configure the HTTP request pipeline.

View File

@@ -40,7 +40,7 @@ public class TableManagerTests {
}
[Fact]
public void LoadPage_ReturnsPagedData() {
public async Task LoadPage_ReturnsPagedData() {
// Arrange
var data = new List<MockModel> {
new MockModel { Id = 1, Name = "Item1" },
@@ -54,7 +54,7 @@ public class TableManagerTests {
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
// Act
var result = manager.LoadPage(1, 2).ToList();
var result = (await manager.LoadPage(1, 2)).ToArray();
// Assert
Assert.Single(result);

View File

@@ -33,7 +33,7 @@ public class HopFrameTablePageTests : TestContext {
contextExplorerMock.Setup(e => e.GetTable("Table1")).Returns(tableConfig);
contextExplorerMock.Setup(e => e.GetTableManager("Table1")).Returns(managerMock.Object);
authHandlerMock.Setup(h => h.IsAuthenticatedAsync(It.IsAny<string>())).ReturnsAsync(true);
managerMock.Setup(m => m.LoadPage(It.IsAny<int>(), It.IsAny<int>())).Returns(Enumerable.Empty<object>().AsAsyncQueryable());
managerMock.Setup(m => m.LoadPage(It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync([]);
Services.AddHopFrame(config, null, false);
Services.AddSingleton(contextExplorerMock.Object);
@@ -71,7 +71,7 @@ public class HopFrameTablePageTests : TestContext {
var tableManagerMock = new Mock<ITableManager>();
var items = new List<object> { new MyTable(), new MyTable() };
tableManagerMock.Setup(m => m.LoadPage(It.IsAny<int>(), It.IsAny<int>())).Returns(items.AsAsyncQueryable());
tableManagerMock.Setup(m => m.LoadPage(It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync(items);
tableManagerMock.Setup(t => t.DisplayProperty(It.IsAny<object>(), It.IsAny<PropertyConfig>(), null, null))
.ReturnsAsync(string.Empty);