Merge branch 'feature/advanced-search' into 'dev'
Resolve "Advanced search" Closes #27 See merge request leon.hoppe/hopframe!35
This commit was merged in pull request #73.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="RiderProjectSettingsUpdater">
|
<component name="RiderProjectSettingsUpdater">
|
||||||
<option name="singleClickDiffPreview" value="1" />
|
<option name="singleClickDiffPreview" value="1" />
|
||||||
|
<option name="unhandledExceptionsIgnoreList" value="1" />
|
||||||
<option name="vcsConfiguration" value="3" />
|
<option name="vcsConfiguration" value="3" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
193
.idea/.idea.HopFrame/.idea/workspace.xml
generated
193
.idea/.idea.HopFrame/.idea/workspace.xml
generated
@@ -11,7 +11,11 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="" />
|
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
|
||||||
|
<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.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/Program.cs" afterDir="false" />
|
||||||
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
@@ -109,7 +113,7 @@
|
|||||||
"associatedIndex": 3
|
"associatedIndex": 3
|
||||||
}</component>
|
}</component>
|
||||||
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
|
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
|
||||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
|
<component name="ProjectLevelVcsManager">
|
||||||
<OptionsSetting value="false" id="Update" />
|
<OptionsSetting value="false" id="Update" />
|
||||||
<ConfirmationsSetting value="2" id="Add" />
|
<ConfirmationsSetting value="2" id="Add" />
|
||||||
</component>
|
</component>
|
||||||
@@ -117,28 +121,28 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
|
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
|
||||||
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
|
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
|
||||||
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
|
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
|
||||||
".NET Project.HopFrame.Testing.executor": "Run",
|
".NET Project.HopFrame.Testing.executor": "Run",
|
||||||
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
|
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
|
||||||
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
|
||||||
"git-widget-placeholder": "!34 on feature/repositories",
|
"git-widget-placeholder": "!35 on feature/advanced-search",
|
||||||
"list.type.of.created.stylesheet": "CSS",
|
"list.type.of.created.stylesheet": "CSS",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}</component>
|
}]]></component>
|
||||||
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
|
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
|
||||||
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
<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" />
|
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
|
||||||
@@ -259,38 +263,13 @@
|
|||||||
<workItem from="1740742098571" duration="78000" />
|
<workItem from="1740742098571" duration="78000" />
|
||||||
<workItem from="1740742471317" duration="672000" />
|
<workItem from="1740742471317" duration="672000" />
|
||||||
<workItem from="1741974241977" duration="10854000" />
|
<workItem from="1741974241977" duration="10854000" />
|
||||||
</task>
|
<workItem from="1742038098473" duration="990000" />
|
||||||
<task id="LOCAL-00002" summary="Added admin page navigation">
|
<workItem from="1742059898156" duration="3488000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1744725284649" duration="60000" />
|
||||||
<created>1736855209077</created>
|
<workItem from="1744916016381" duration="66000" />
|
||||||
<option name="number" value="00002" />
|
<workItem from="1744916106166" duration="49000" />
|
||||||
<option name="presentableId" value="LOCAL-00002" />
|
<workItem from="1744966207145" duration="5231000" />
|
||||||
<option name="project" value="LOCAL" />
|
<workItem from="1751713720880" duration="7712000" />
|
||||||
<updated>1736855209077</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00003" summary="Added database loading logic">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1736859917232</created>
|
|
||||||
<option name="number" value="00003" />
|
|
||||||
<option name="presentableId" value="LOCAL-00003" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1736859917232</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00004" summary="Started working on listing page">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1736885531216</created>
|
|
||||||
<option name="number" value="00004" />
|
|
||||||
<option name="presentableId" value="LOCAL-00004" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1736885531216</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00005" summary="Added entry saving support">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1736970238802</created>
|
|
||||||
<option name="number" value="00005" />
|
|
||||||
<option name="presentableId" value="LOCAL-00005" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1736970238802</updated>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00006" summary="Added reload button and animation">
|
<task id="LOCAL-00006" summary="Added reload button and animation">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -652,7 +631,39 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1741985203179</updated>
|
<updated>1741985203179</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="51" />
|
<task id="LOCAL-00051" summary="Added documentation for custom repos and exporter plugin">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1742038459077</created>
|
||||||
|
<option name="number" value="00051" />
|
||||||
|
<option name="presentableId" value="LOCAL-00051" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1742038459077</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00052" summary="Implemented sql search + negatable searches">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1742063374318</created>
|
||||||
|
<option name="number" value="00052" />
|
||||||
|
<option name="presentableId" value="LOCAL-00052" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1742063374318</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00053" summary="Started working on search suggestions">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1744971440348</created>
|
||||||
|
<option name="number" value="00053" />
|
||||||
|
<option name="presentableId" value="LOCAL-00053" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1744971440348</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00054" summary="Finished advanced search functionality">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1751721064458</created>
|
||||||
|
<option name="number" value="00054" />
|
||||||
|
<option name="presentableId" value="LOCAL-00054" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1751721064458</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="55" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -662,38 +673,17 @@
|
|||||||
<option name="coveragePercentColumnWidth" value="129" />
|
<option name="coveragePercentColumnWidth" value="129" />
|
||||||
<option name="sortOrder" value="DESCENDING" />
|
<option name="sortOrder" value="DESCENDING" />
|
||||||
<option name="sortedColumn" value="1" />
|
<option name="sortedColumn" value="1" />
|
||||||
<option name="symbolColumnWidth" value="451" />
|
<option name="symbolColumnWidth" value="559" />
|
||||||
<coverage-tree-state>
|
<coverage-tree-state>
|
||||||
<expand>
|
<expand>
|
||||||
<path>
|
<path>
|
||||||
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
||||||
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
<item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
||||||
</path>
|
</path>
|
||||||
<path>
|
<path>
|
||||||
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
||||||
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
<item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
||||||
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
<item name="Core 40% 862/1439" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
||||||
</path>
|
|
||||||
<path>
|
|
||||||
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
|
||||||
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
</path>
|
|
||||||
<path>
|
|
||||||
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
|
||||||
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
</path>
|
|
||||||
<path>
|
|
||||||
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
|
|
||||||
<item name="Total 0% 1657/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="Core 0% 743/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="HopFrame.Core 0% 367/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
<item name="Config 0% 228/228" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
|
|
||||||
</path>
|
</path>
|
||||||
</expand>
|
</expand>
|
||||||
<select />
|
<select />
|
||||||
@@ -703,10 +693,6 @@
|
|||||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
||||||
<component name="VcsManagerConfiguration">
|
<component name="VcsManagerConfiguration">
|
||||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||||
<MESSAGE value="Added maximum display length" />
|
|
||||||
<MESSAGE value="Fixed test for table view" />
|
|
||||||
<MESSAGE value="Added n-m relation mapping" />
|
|
||||||
<MESSAGE value="Fixed wrong element selection for action buttons" />
|
|
||||||
<MESSAGE value="Implemented primitive change reversion" />
|
<MESSAGE value="Implemented primitive change reversion" />
|
||||||
<MESSAGE value="Implemented deferred entry manipulation" />
|
<MESSAGE value="Implemented deferred entry manipulation" />
|
||||||
<MESSAGE value="Removed select all button" />
|
<MESSAGE value="Removed select all button" />
|
||||||
@@ -728,6 +714,41 @@
|
|||||||
<MESSAGE value="Fixed directory in pipeline" />
|
<MESSAGE value="Fixed directory in pipeline" />
|
||||||
<MESSAGE value="Reverted pipeline to include all jobs" />
|
<MESSAGE value="Reverted pipeline to include all jobs" />
|
||||||
<MESSAGE value="Added support for custom repositories" />
|
<MESSAGE value="Added support for custom repositories" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="Added support for custom repositories" />
|
<MESSAGE value="Added documentation for custom repos and exporter plugin" />
|
||||||
|
<MESSAGE value="Implemented sql search + negatable searches" />
|
||||||
|
<MESSAGE value="Started working on search suggestions" />
|
||||||
|
<MESSAGE value="Finished advanced search functionality" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Finished advanced search functionality" />
|
||||||
|
</component>
|
||||||
|
<component name="XDebuggerManager">
|
||||||
|
<breakpoint-manager>
|
||||||
|
<breakpoints>
|
||||||
|
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||||
|
<properties exception="System.OperationCanceledException" breakIfHandledByOtherCode="false" displayValue="System.OperationCanceledException" />
|
||||||
|
<option name="timeStamp" value="1" />
|
||||||
|
</breakpoint>
|
||||||
|
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||||
|
<properties exception="System.Threading.Tasks.TaskCanceledException" breakIfHandledByOtherCode="false" displayValue="System.Threading.Tasks.TaskCanceledException" />
|
||||||
|
<option name="timeStamp" value="2" />
|
||||||
|
</breakpoint>
|
||||||
|
<breakpoint enabled="true" type="DotNet_Exception_Breakpoints">
|
||||||
|
<properties exception="System.Threading.ThreadAbortException" breakIfHandledByOtherCode="false" displayValue="System.Threading.ThreadAbortException" />
|
||||||
|
<option name="timeStamp" value="3" />
|
||||||
|
</breakpoint>
|
||||||
|
<line-breakpoint enabled="true" type="DotNet Breakpoints">
|
||||||
|
<url>file://$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/SearchExpressionBuilder.cs</url>
|
||||||
|
<line>102</line>
|
||||||
|
<properties documentPath="C:\Users\leon\Documents\Projekte\HopFrame\src\HopFrame.Core\Services\Implementations\SearchExpressionBuilder.cs" containingFunctionPresentation="Method 'BuildSearchExpression'">
|
||||||
|
<startOffsets>
|
||||||
|
<option value="4460" />
|
||||||
|
</startOffsets>
|
||||||
|
<endOffsets>
|
||||||
|
<option value="4503" />
|
||||||
|
</endOffsets>
|
||||||
|
</properties>
|
||||||
|
<option name="timeStamp" value="12" />
|
||||||
|
</line-breakpoint>
|
||||||
|
</breakpoints>
|
||||||
|
</breakpoint-manager>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -15,6 +15,7 @@ public class TableConfig {
|
|||||||
public bool Ignored { get; set; }
|
public bool Ignored { get; set; }
|
||||||
public int Order { get; set; }
|
public int Order { get; set; }
|
||||||
internal bool Seeded { get; set; }
|
internal bool Seeded { get; set; }
|
||||||
|
public bool ShowSearchSuggestions { get; set; } = true;
|
||||||
|
|
||||||
public string? ViewPolicy { get; set; }
|
public string? ViewPolicy { get; set; }
|
||||||
public string? CreatePolicy { get; set; }
|
public string? CreatePolicy { get; set; }
|
||||||
@@ -66,6 +67,13 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
|
|||||||
InnerConfig.Ignored = ignore;
|
InnerConfig.Ignored = ignore;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if search suggestions should be displayed in the ui (Advanced Search)
|
||||||
|
/// </summary>
|
||||||
|
public TableConfigurator<TModel> ShowSearchSuggestions(bool show = true) {
|
||||||
|
InnerConfig.ShowSearchSuggestions = show;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures the property of the table
|
/// Configures the property of the table
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions {
|
|||||||
services.AddScoped<IContextExplorer, ContextExplorer>();
|
services.AddScoped<IContextExplorer, ContextExplorer>();
|
||||||
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
|
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
|
||||||
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
|
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
|
||||||
|
services.AddScoped<ISearchExpressionBuilder, SearchExpressionBuilder>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
src/HopFrame.Core/Services/ISearchExpressionBuilder.cs
Normal file
9
src/HopFrame.Core/Services/ISearchExpressionBuilder.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using HopFrame.Core.Config;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Services;
|
||||||
|
|
||||||
|
public interface ISearchExpressionBuilder {
|
||||||
|
Expression? BuildSearchExpression(TableConfig table, string searchTerm, ParameterExpression parameter);
|
||||||
|
}
|
||||||
@@ -50,12 +50,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
|||||||
|
|
||||||
if (context is DbContextConfig) {
|
if (context is DbContextConfig) {
|
||||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
|
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context is RepositoryGroupConfig repoConfig) {
|
if (context is RepositoryGroupConfig repoConfig) {
|
||||||
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
||||||
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
|
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +72,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
|||||||
|
|
||||||
if (context is DbContextConfig) {
|
if (context is DbContextConfig) {
|
||||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||||
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider) as ITableManager;
|
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context is RepositoryGroupConfig repoConfig) {
|
if (context is RepositoryGroupConfig repoConfig) {
|
||||||
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
|
||||||
return Activator.CreateInstance(type, repo, this, provider) as ITableManager;
|
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using HopFrame.Core.Repositories;
|
|||||||
|
|
||||||
namespace HopFrame.Core.Services.Implementations;
|
namespace HopFrame.Core.Services.Implementations;
|
||||||
|
|
||||||
public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TKey> repo, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
|
public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TKey> repo, IContextExplorer explorer, IServiceProvider provider, ISearchExpressionBuilder searchExpressionBuilder) : ITableManager where TModel : class {
|
||||||
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
||||||
return await repo.LoadPage(page, perPage);
|
return await repo.LoadPage(page, perPage);
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ public class RepositoryTableManager<TModel, TKey>(IHopFrameRepository<TModel, TK
|
|||||||
return await repo.GetOne((TKey)key);
|
return await repo.GetOne((TKey)key);
|
||||||
}
|
}
|
||||||
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
|
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
|
||||||
var manager = new TableManager<TModel>(null!, null!, explorer, provider);
|
var manager = new TableManager<TModel>(null!, null!, explorer, provider, searchExpressionBuilder);
|
||||||
return await manager.DisplayProperty(item, prop, value, enumerableValue);
|
return await manager.DisplayProperty(item, prop, value, enumerableValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using HopFrame.Core.Config;
|
||||||
|
|
||||||
|
namespace HopFrame.Core.Services.Implementations;
|
||||||
|
|
||||||
|
internal sealed class SearchExpressionBuilder(IContextExplorer explorer) : ISearchExpressionBuilder {
|
||||||
|
private readonly struct SearchPart {
|
||||||
|
public string? Property { get; init; }
|
||||||
|
public string Term { get; init; }
|
||||||
|
public bool Negated { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression AddPropertySearchExpression(PropertyInfo property, ParameterExpression parameter, string searchTerm, PropertyConfig config) {
|
||||||
|
Expression propertyAccess = Expression.Property(parameter, property);
|
||||||
|
|
||||||
|
if (config.IsEnumerable) { //Call Count() extension method before checking the search term
|
||||||
|
propertyAccess = Expression.Property(propertyAccess, config.Info.PropertyType.GetProperty(nameof(List<object>.Count))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var toStringCall = Expression.Call(propertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||||
|
var searchExpression = Expression.Call(
|
||||||
|
toStringCall,
|
||||||
|
typeof(string).GetMethod(config.IsEnumerable ? nameof(string.Equals) : nameof(string.Contains), [typeof(string)])!,
|
||||||
|
Expression.Constant(searchTerm));
|
||||||
|
|
||||||
|
return searchExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression AddForeignPropertySearchExpression(PropertyInfo navigationProperty, PropertyInfo displayedProperty, ParameterExpression parameter, string searchTerm) {
|
||||||
|
var navigationAccess = Expression.Property(parameter, navigationProperty);
|
||||||
|
var nullCheck = Expression.NotEqual(navigationAccess, Expression.Constant(null));
|
||||||
|
var displayedPropertyAccess = Expression.Property(navigationAccess, displayedProperty);
|
||||||
|
|
||||||
|
var toStringCall = Expression.Call(displayedPropertyAccess, nameof(ToString), Type.EmptyTypes);
|
||||||
|
var searchExpression = Expression.Call(
|
||||||
|
toStringCall,
|
||||||
|
typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!,
|
||||||
|
Expression.Constant(searchTerm));
|
||||||
|
|
||||||
|
return Expression.AndAlso(nullCheck, searchExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<PropertyInfo> GetSuitableProperties(TableConfig table) {
|
||||||
|
Type[] validTypes = [typeof(string), typeof(Guid), typeof(DateTime), typeof(DateOnly), typeof(TimeOnly)];
|
||||||
|
|
||||||
|
return table.Properties
|
||||||
|
.Where(prop => !prop.IsVirtualProperty)
|
||||||
|
.Where(prop => prop.List)
|
||||||
|
.Where(prop => prop.Searchable)
|
||||||
|
.Where(prop => prop.Info.PropertyType.IsEnum || validTypes.Contains(prop.Info.PropertyType) || prop.IsEnumerable)
|
||||||
|
.Select(prop => prop.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(PropertyInfo navigation, PropertyInfo display)> GetSuitableForeignProperties(TableConfig table) {
|
||||||
|
return table.Properties
|
||||||
|
.Where(prop => prop.List)
|
||||||
|
.Where(prop => prop.IsRelation)
|
||||||
|
.Where(prop => prop.Searchable)
|
||||||
|
.Where(prop => prop.DisplayedProperty != null)
|
||||||
|
.Select(prop => (prop.Info, explorer
|
||||||
|
.GetTable(prop.Info.PropertyType)!.Properties
|
||||||
|
.Find(p => p.Info.Name == prop.DisplayedProperty!.Name)!
|
||||||
|
.Info));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<SearchPart> ExtractSearchParts(string searchTerm) {
|
||||||
|
var rawParts = searchTerm.Split(' ');
|
||||||
|
var parts = new List<SearchPart>();
|
||||||
|
|
||||||
|
foreach (var part in rawParts) {
|
||||||
|
if (string.IsNullOrWhiteSpace(part))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!part.Contains('=')) {
|
||||||
|
var negated = part.StartsWith('!');
|
||||||
|
|
||||||
|
parts.Add(new() {
|
||||||
|
Term = negated ? part[1..] : part,
|
||||||
|
Negated = negated,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var split = part.Split('=');
|
||||||
|
var term = string.Join('=', split[1..]);
|
||||||
|
var termNegated = term.StartsWith('!');
|
||||||
|
|
||||||
|
parts.Add(new() {
|
||||||
|
Property = split[0],
|
||||||
|
Term = termNegated ? term[1..] : term,
|
||||||
|
Negated = termNegated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Expression? BuildSearchExpression(TableConfig table, string searchTerm, ParameterExpression parameter) {
|
||||||
|
var properties = GetSuitableProperties(table).ToArray();
|
||||||
|
var foreignProperties = GetSuitableForeignProperties(table).ToArray();
|
||||||
|
|
||||||
|
var parts = ExtractSearchParts(searchTerm);
|
||||||
|
|
||||||
|
Expression? expression = null;
|
||||||
|
foreach (var part in parts) {
|
||||||
|
Expression? subExp = null;
|
||||||
|
|
||||||
|
if (part.Property is null) {
|
||||||
|
foreach (var property in properties) {
|
||||||
|
var exp = AddPropertySearchExpression(property, parameter, part.Term, table.Properties.First(p => p.Info == property));
|
||||||
|
subExp = subExp is null
|
||||||
|
? exp
|
||||||
|
: Expression.OrElse(subExp, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in foreignProperties) {
|
||||||
|
var exp = AddForeignPropertySearchExpression(property.navigation, property.display, parameter, part.Term);
|
||||||
|
subExp = subExp is null
|
||||||
|
? exp
|
||||||
|
: Expression.OrElse(subExp, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subExp is null)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prop = properties.FirstOrDefault(p => p.Name == part.Property);
|
||||||
|
if (prop is not null) {
|
||||||
|
subExp = AddPropertySearchExpression(prop, parameter, part.Term, table.Properties.First(p => p.Info == prop));
|
||||||
|
}
|
||||||
|
|
||||||
|
var forProp = foreignProperties.FirstOrDefault(p => p.navigation.Name == part.Property);
|
||||||
|
if (forProp.navigation is not null) {
|
||||||
|
subExp = AddForeignPropertySearchExpression(forProp.navigation, forProp.display, parameter, part.Term);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subExp is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (part.Negated)
|
||||||
|
subExp = Expression.Not(subExp);
|
||||||
|
|
||||||
|
expression = expression is null
|
||||||
|
? subExp
|
||||||
|
: Expression.AndAlso(expression, subExp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Reflection.Metadata;
|
||||||
using HopFrame.Core.Config;
|
using HopFrame.Core.Config;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
|
||||||
namespace HopFrame.Core.Services.Implementations;
|
namespace HopFrame.Core.Services.Implementations;
|
||||||
|
|
||||||
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
|
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider, ISearchExpressionBuilder searchExpressionBuilder) : ITableManager where TModel : class {
|
||||||
|
|
||||||
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
|
||||||
var table = context.Set<TModel>();
|
var table = context.Set<TModel>();
|
||||||
@@ -17,18 +20,29 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
|
|||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
|
public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
|
||||||
var table = context.Set<TModel>();
|
var table = context.Set<TModel>();
|
||||||
var all = IncludeForeignKeys(table)
|
|
||||||
.AsEnumerable()
|
|
||||||
.Where(item => ItemSearched(item, searchTerm))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Task.FromResult((
|
var parameter = Expression.Parameter(typeof(TModel), "x");
|
||||||
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage),
|
var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
|
||||||
(int)Math.Ceiling(all.Count / (double)perPage)));
|
|
||||||
|
if (exp is null)
|
||||||
|
return ([], 0);
|
||||||
|
|
||||||
|
var lambda = Expression.Lambda<Func<TModel, bool>>(exp, parameter);
|
||||||
|
var result = await IncludeForeignKeys(table)
|
||||||
|
.Where(lambda)
|
||||||
|
.Skip(page * perPage)
|
||||||
|
.Take(perPage)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalEntries = await table
|
||||||
|
.Where(lambda)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
return (result, (int)Math.Ceiling(totalEntries / (double)perPage));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> TotalPages(int perPage = 20) {
|
public async Task<int> TotalPages(int perPage = 20) {
|
||||||
var table = context.Set<TModel>();
|
var table = context.Set<TModel>();
|
||||||
return (int)Math.Ceiling(await table.CountAsync() / (double)perPage);
|
return (int)Math.Ceiling(await table.CountAsync() / (double)perPage);
|
||||||
@@ -61,31 +75,6 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
|
|||||||
return await table.FindAsync(key);
|
return await table.FindAsync(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RevertChanges(object item) {
|
|
||||||
var entry = context.Entry((TModel)item);
|
|
||||||
await entry.ReloadAsync();
|
|
||||||
|
|
||||||
if (entry.Collections.Any()) {
|
|
||||||
context.ChangeTracker.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ItemSearched(TModel item, string searchTerm) {
|
|
||||||
foreach (var property in config.Properties) {
|
|
||||||
if (!property.Searchable) continue;
|
|
||||||
var value = property.GetValue(item, provider);
|
|
||||||
if (value is null) continue;
|
|
||||||
|
|
||||||
var strValue = value.ToString();
|
|
||||||
if (strValue?.Contains(searchTerm) == true)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
|
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
|
||||||
if (item is null) return string.Empty;
|
if (item is null) return string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
@using HopFrame.Web.Models
|
@using HopFrame.Web.Models
|
||||||
@using HopFrame.Web.Plugins
|
@using HopFrame.Web.Plugins
|
||||||
@using HopFrame.Web.Plugins.Events
|
@using HopFrame.Web.Plugins.Events
|
||||||
|
@using HopFrame.Web.Services
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
|
|
||||||
@if (!DisplaySelection) {
|
@if (!DisplaySelection) {
|
||||||
<PageTitle>@_config?.DisplayName</PageTitle>
|
<PageTitle>@_config?.DisplayName</PageTitle>
|
||||||
@@ -40,7 +40,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<FluentSpacer />
|
<FluentSpacer />
|
||||||
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" />
|
<div
|
||||||
|
style="position: relative; height: 32px"
|
||||||
|
class="hopframe-search">
|
||||||
|
|
||||||
|
<FluentSearch
|
||||||
|
@ref="_searchBox"
|
||||||
|
@oninput="OnSearch"
|
||||||
|
@onchange="OnSearch"
|
||||||
|
@onfocusin="() => { SearchFocus(); UpdateSearchSuggestions(); }"
|
||||||
|
@onfocusout="SearchUnfocus"
|
||||||
|
Style="width: 500px"/>
|
||||||
|
|
||||||
|
@if (_isSearchActive && _searchSuggestions.Count > 0) {
|
||||||
|
<FluentListbox
|
||||||
|
TOption="string"
|
||||||
|
Items="_searchSuggestions"
|
||||||
|
SelectedOptionChanged="SearchSuggestionSelected"
|
||||||
|
@onfocusin="SearchFocus"
|
||||||
|
@onfocusout="SearchUnfocus"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
|
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
|
||||||
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entity</FluentButton>
|
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entity</FluentButton>
|
||||||
@@ -162,6 +182,7 @@
|
|||||||
@inject IHopFrameAuthHandler Handler
|
@inject IHopFrameAuthHandler Handler
|
||||||
@inject ICallbackEmitter Emitter
|
@inject ICallbackEmitter Emitter
|
||||||
@inject IPluginOrchestrator PluginOrchestrator
|
@inject IPluginOrchestrator PluginOrchestrator
|
||||||
|
@inject ISearchSuggestionProvider SearchSuggestions
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|
||||||
@@ -191,6 +212,9 @@
|
|||||||
private int _totalPages;
|
private int _totalPages;
|
||||||
private string? _searchTerm;
|
private string? _searchTerm;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
|
private bool _isSearchActive;
|
||||||
|
private IList<string> _searchSuggestions = [];
|
||||||
|
private FluentSearch? _searchBox;
|
||||||
|
|
||||||
private bool _hasUpdatePolicy;
|
private bool _hasUpdatePolicy;
|
||||||
private bool _hasDeletePolicy;
|
private bool _hasDeletePolicy;
|
||||||
@@ -255,6 +279,7 @@
|
|||||||
_searchTerm = eventArgs.Value?.ToString();
|
_searchTerm = eventArgs.Value?.ToString();
|
||||||
if (_searchTerm is null) return;
|
if (_searchTerm is null) return;
|
||||||
_searchCancel = new();
|
_searchCancel = new();
|
||||||
|
UpdateSearchSuggestions();
|
||||||
|
|
||||||
await Task.Delay(500, _searchCancel.Token);
|
await Task.Delay(500, _searchCancel.Token);
|
||||||
|
|
||||||
@@ -274,6 +299,36 @@
|
|||||||
|
|
||||||
await Reload();
|
await Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SearchSuggestionSelected(string? suggestion) {
|
||||||
|
if (string.IsNullOrWhiteSpace(suggestion)) return;
|
||||||
|
_searchTerm = SearchSuggestions.CompleteSearchSuggestion(_config!, _searchTerm ?? string.Empty, suggestion);
|
||||||
|
_searchBox!.Value = _searchTerm;
|
||||||
|
_searchBox.FocusAsync();
|
||||||
|
UpdateSearchSuggestions();
|
||||||
|
|
||||||
|
if (!suggestion.EndsWith('='))
|
||||||
|
await OnSearch(new() {
|
||||||
|
Value = _searchTerm
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSearchSuggestions() {
|
||||||
|
if (_config is null || !_config.ShowSearchSuggestions) return;
|
||||||
|
_searchSuggestions = SearchSuggestions.GenerateSearchSuggestions(_config, _searchTerm ?? string.Empty).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CancellationTokenSource _searchFocusCancel = new();
|
||||||
|
private async Task SearchFocus() {
|
||||||
|
_isSearchActive = true;
|
||||||
|
await _searchFocusCancel.CancelAsync();
|
||||||
|
_searchFocusCancel = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchUnfocus() {
|
||||||
|
await Task.Delay(10, _searchFocusCancel.Token);
|
||||||
|
_isSearchActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task Reload() {
|
public async Task Reload() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@@ -421,4 +476,5 @@
|
|||||||
public void RequestRender() {
|
public void RequestRender() {
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,13 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
|
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hopframe-search ::deep fluent-listbox {
|
||||||
|
width: 500px;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background-color: var(--fill-color);
|
||||||
|
z-index: 1;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using HopFrame.Web.Services.Implementation;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.FluentUI.AspNetCore.Components;
|
using Microsoft.FluentUI.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
namespace HopFrame.Web;
|
namespace HopFrame.Web;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ public static class ServiceCollectionExtensions {
|
|||||||
|
|
||||||
services.AddScoped<IPluginOrchestrator, PluginOrchestrator>();
|
services.AddScoped<IPluginOrchestrator, PluginOrchestrator>();
|
||||||
services.AddScoped<IFileService, FileService>();
|
services.AddScoped<IFileService, FileService>();
|
||||||
|
services.TryAddScoped<ISearchSuggestionProvider, SearchSuggestionProvider>();
|
||||||
|
|
||||||
if (addRazorComponents) {
|
if (addRazorComponents) {
|
||||||
services.AddRazorComponents()
|
services.AddRazorComponents()
|
||||||
|
|||||||
11
src/HopFrame.Web/Services/ISearchSuggestionProvider.cs
Normal file
11
src/HopFrame.Web/Services/ISearchSuggestionProvider.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using HopFrame.Core.Config;
|
||||||
|
|
||||||
|
namespace HopFrame.Web.Services;
|
||||||
|
|
||||||
|
public interface ISearchSuggestionProvider {
|
||||||
|
|
||||||
|
public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText);
|
||||||
|
|
||||||
|
public string CompleteSearchSuggestion(TableConfig table, string searchText, string selectedSuggestion);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using HopFrame.Core.Config;
|
||||||
|
using HopFrame.Core.Services;
|
||||||
|
using HopFrame.Web.Helpers;
|
||||||
|
|
||||||
|
namespace HopFrame.Web.Services.Implementation;
|
||||||
|
|
||||||
|
public sealed class SearchSuggestionProvider : ISearchSuggestionProvider {
|
||||||
|
|
||||||
|
public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText) {
|
||||||
|
var searchParts = searchText.Trim().Split(' ');
|
||||||
|
if (searchParts.Length != 0 && searchParts.Last().EndsWith('=') && !searchText.EndsWith(' ')) {
|
||||||
|
var part = searchParts.Last()[..^1];
|
||||||
|
var property = table.Properties
|
||||||
|
.Where(p => p.List)
|
||||||
|
.Where(p => !p.IsVirtualProperty)
|
||||||
|
.FirstOrDefault(p => p.Name == part);
|
||||||
|
|
||||||
|
if (property is null) return [];
|
||||||
|
|
||||||
|
if (property.Info.PropertyType.IsEnum)
|
||||||
|
return Enum.GetNames(property.Info.PropertyType);
|
||||||
|
|
||||||
|
if (property.Info.PropertyType == typeof(DateOnly))
|
||||||
|
return [DateOnly.FromDateTime(DateTime.Now).ToString()];
|
||||||
|
|
||||||
|
if (property.Info.PropertyType == typeof(TimeOnly))
|
||||||
|
return [TimeOnly.FromDateTime(DateTime.Now).ToString()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText.Length != 0 && !searchText.EndsWith(' '))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
Type[] validTypes = [typeof(string), typeof(Guid), typeof(bool), typeof(DateOnly), typeof(TimeOnly)];
|
||||||
|
var searchableProperties = table.Properties
|
||||||
|
.Where(p => !p.IsVirtualProperty)
|
||||||
|
.Where(p => p.List)
|
||||||
|
.Where(p => p.Searchable)
|
||||||
|
.Where(p =>
|
||||||
|
p.Info.PropertyType.IsEnum ||
|
||||||
|
p.Info.PropertyType.IsNumeric() ||
|
||||||
|
validTypes.Contains(p.Info.PropertyType) ||
|
||||||
|
p.IsRelation)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return searchableProperties
|
||||||
|
.Select(p => p.Name + "=");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CompleteSearchSuggestion(TableConfig table, string searchText, string selectedSuggestion) {
|
||||||
|
return searchText + selectedSuggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -12,8 +12,4 @@ public class User {
|
|||||||
public string? LastName { get; set; }
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
public virtual List<Post> Posts { get; set; } = new();
|
public virtual List<Post> Posts { get; set; } = new();
|
||||||
|
|
||||||
public override string ToString() {
|
|
||||||
return Username;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -51,9 +51,13 @@ builder.Services.AddHopFrame(options => {
|
|||||||
.FormatEach<Post>((post, _) => post.Caption);
|
.FormatEach<Post>((post, _) => post.Caption);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
context.Table<Post>()
|
||||||
|
.ShowSearchSuggestions(false);
|
||||||
|
|
||||||
context.Table<Post>()
|
context.Table<Post>()
|
||||||
.Property(p => p.Author)
|
.Property(p => p.Author)
|
||||||
.Format((user, _) => $"{user.FirstName} {user.LastName}")
|
//.Format((user, _) => $"{user.FirstName} {user.LastName}")
|
||||||
|
.SetDisplayedProperty(u => u.Username)
|
||||||
.SetValidator((_, _) => []);
|
.SetValidator((_, _) => []);
|
||||||
|
|
||||||
context.Table<Post>()
|
context.Table<Post>()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using HopFrame.Core.Config;
|
using HopFrame.Core.Config;
|
||||||
|
using HopFrame.Core.Services;
|
||||||
using HopFrame.Core.Services.Implementations;
|
using HopFrame.Core.Services.Implementations;
|
||||||
using HopFrame.Tests.Core.Models;
|
using HopFrame.Tests.Core.Models;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ public class ContextExplorerTests {
|
|||||||
var dbContext = new MockDbContext();
|
var dbContext = new MockDbContext();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
provider.Setup(p => p.GetService(typeof(MockDbContext))).Returns(dbContext);
|
provider.Setup(p => p.GetService(typeof(MockDbContext))).Returns(dbContext);
|
||||||
|
provider.Setup(p => p.GetService(typeof(ISearchExpressionBuilder))).Returns(new Mock<ISearchExpressionBuilder>().Object);
|
||||||
var contextExplorer = new ContextExplorer(config, provider.Object, new Logger<ContextExplorer>(new LoggerFactory()));
|
var contextExplorer = new ContextExplorer(config, provider.Object, new Logger<ContextExplorer>(new LoggerFactory()));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class DisplayPropertyTests {
|
|||||||
_explorerMock = new Mock<IContextExplorer>();
|
_explorerMock = new Mock<IContextExplorer>();
|
||||||
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
_tableManager =
|
_tableManager =
|
||||||
new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object);
|
new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object, new SearchExpressionBuilder(_explorerMock.Object));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ using Moq;
|
|||||||
namespace HopFrame.Tests.Core.Services;
|
namespace HopFrame.Tests.Core.Services;
|
||||||
|
|
||||||
public class TableManagerTests {
|
public class TableManagerTests {
|
||||||
|
|
||||||
|
private Mock<ISearchExpressionBuilder> _searchBuilderMock = new();
|
||||||
|
|
||||||
private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class {
|
private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class {
|
||||||
var dbContext = new Mock<DbContext>();
|
var dbContext = new Mock<DbContext>();
|
||||||
var dbSet = CreateMockDbSet(data);
|
var dbSet = CreateMockDbSet(data);
|
||||||
@@ -51,7 +54,7 @@ public class TableManagerTests {
|
|||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
var explorer = new Mock<IContextExplorer>();
|
var explorer = new Mock<IContextExplorer>();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = (await manager.LoadPage(1, 2)).ToArray();
|
var result = (await manager.LoadPage(1, 2)).ToArray();
|
||||||
@@ -61,32 +64,6 @@ public class TableManagerTests {
|
|||||||
Assert.Equal("Item3", ((MockModel)result[0]).Name);
|
Assert.Equal("Item3", ((MockModel)result[0]).Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Search_ReturnsMatchingData() {
|
|
||||||
// Arrange
|
|
||||||
var data = new List<MockModel> {
|
|
||||||
new MockModel { Id = 1, Name = "Item1" },
|
|
||||||
new MockModel { Id = 2, Name = "Item2" },
|
|
||||||
new MockModel { Id = 3, Name = "TestItem" }
|
|
||||||
};
|
|
||||||
var dbContext = CreateMockDbContext(data);
|
|
||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
|
||||||
config.Properties.Add(new PropertyConfig(typeof(MockModel).GetProperty("Name")!, config, 0)
|
|
||||||
{ Searchable = true });
|
|
||||||
var explorer = new Mock<IContextExplorer>();
|
|
||||||
var provider = new Mock<IServiceProvider>();
|
|
||||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var (result, totalPages) = await manager.Search("Test", 0, 2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var collection = result as object[] ?? result.ToArray();
|
|
||||||
Assert.Single(collection);
|
|
||||||
Assert.Equal("TestItem", ((MockModel)collection.First()).Name);
|
|
||||||
Assert.Equal(1, totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TotalPages_ReturnsCorrectPageCount() {
|
public async Task TotalPages_ReturnsCorrectPageCount() {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -99,7 +76,7 @@ public class TableManagerTests {
|
|||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
var explorer = new Mock<IContextExplorer>();
|
var explorer = new Mock<IContextExplorer>();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
var manager = new TableManager<MockModel>(dbContext, config, explorer.Object, provider.Object);
|
var manager = new TableManager<MockModel>(dbContext, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||||
await dbContext.Models.AddRangeAsync(data);
|
await dbContext.Models.AddRangeAsync(data);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -121,7 +98,7 @@ public class TableManagerTests {
|
|||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
var explorer = new Mock<IContextExplorer>();
|
var explorer = new Mock<IContextExplorer>();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||||
var item = data.First();
|
var item = data.First();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -142,7 +119,7 @@ public class TableManagerTests {
|
|||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
var explorer = new Mock<IContextExplorer>();
|
var explorer = new Mock<IContextExplorer>();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await manager.EditItem(data.First());
|
await manager.EditItem(data.First());
|
||||||
@@ -159,7 +136,7 @@ public class TableManagerTests {
|
|||||||
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
|
||||||
var explorer = new Mock<IContextExplorer>();
|
var explorer = new Mock<IContextExplorer>();
|
||||||
var provider = new Mock<IServiceProvider>();
|
var provider = new Mock<IServiceProvider>();
|
||||||
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object);
|
var manager = new TableManager<MockModel>(dbContext.Object, config, explorer.Object, provider.Object, _searchBuilderMock.Object);
|
||||||
var newItem = new MockModel { Id = 3, Name = "NewItem" };
|
var newItem = new MockModel { Id = 3, Name = "NewItem" };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
Reference in New Issue
Block a user