15 Commits

Author SHA1 Message Date
12c86bcb16 Added ability to use ignored tables in relation picker 2026-01-19 19:51:41 +01:00
a826ddbfb1 Fixed #37 2026-01-19 19:44:25 +01:00
297cf00891 Fixed #38 2026-01-19 19:39:25 +01:00
d393ae787d Code cleanup + new pipeline setup 2026-01-18 17:42:37 +01:00
10913b0a21 Merge branch 'feature/test-reports' into 'dev'
Feature/test reports

See merge request leon.hoppe/hopframe!36
2025-07-06 13:57:38 +02:00
31b0b3970a Feature/test reports 2025-07-06 13:57:38 +02:00
e6726037b6 Merge branch 'feature/advanced-search' into 'dev'
Resolve "Advanced search"

Closes #27

See merge request leon.hoppe/hopframe!35
2025-07-05 13:18:02 +00:00
c5388fc044 Made search suggestions togglable 2025-07-05 15:19:35 +02:00
66d03513eb Finished advanced search functionality 2025-07-05 15:11:02 +02:00
68a4479c2d Started working on search suggestions 2025-04-18 12:17:18 +02:00
5dec609004 Implemented sql search + negatable searches 2025-03-15 19:29:23 +01:00
7d3aa6de94 Merge branch 'feature/repositories' into 'dev'
Resolve "Custom Repositories"

Closes #32

See merge request leon.hoppe/hopframe!34
2025-03-15 11:33:05 +00:00
5c6fafcd6f Added documentation for custom repos and exporter plugin 2025-03-15 12:34:16 +01:00
222d4276d2 Added support for custom repositories 2025-03-14 21:46:41 +01:00
4407d173a9 Reverted pipeline to include all jobs 2025-02-28 12:45:35 +01:00
40 changed files with 1303 additions and 342 deletions

View File

@@ -1,14 +1,67 @@
image: mcr.microsoft.com/dotnet/sdk:9.0 image: mcr.microsoft.com/dotnet/sdk:9.0
stages: stages:
- build
- test
- publish
- publish-help - publish-help
build:
stage: build
only:
- pushes
script:
- dotnet restore
- dotnet build --configuration Release --no-restore
artifacts:
paths:
- "**/bin/Release"
expire_in: 10 minutes
test:
stage: test
only:
- pushes
script:
- dotnet test HopFrame.sln --logger "trx;LogFilePrefix=testresults" --collect:"XPlat Code Coverage" --results-directory TestResults
- dotnet tool install --global trx2junit
- dotnet tool install --global dotnet-reportgenerator-globaltool
- export PATH="$PATH:/root/.dotnet/tools"
- for file in TestResults/*.trx; do trx2junit "$file"; done
- reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:"Cobertura;HtmlInline"
- echo total_coverage=$(grep -o 'line-rate="[0-9.]*"' coveragereport/Cobertura.xml | head -n1 | sed -E 's/line-rate="([0-9.]+)"/\1/' | awk '{printf "%.2f", $1 * 100}')
- tar -cvf coveragereport.tar coveragereport/
artifacts:
when: always
expire_in: 1 week
paths:
- TestResults/*.xml
- TestResults/**/*.coverage.cobertura.xml
- coveragereport.tar
reports:
junit:
- TestResults/*.xml
coverage_report:
coverage_format: cobertura
path: coveragereport/Cobertura.xml
coverage: '/total_coverage=(\d+(\.\d+)?)/'
publish:
stage: publish
script:
- export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//')
- dotnet pack -c Release -o . /p:Version=$VERSION
- for nupkg in *.nupkg; do dotnet nuget push $nupkg -k ${NUGET_API_KEY} -s https://api.nuget.org/v3/index.json; done
only:
- tags
dependencies:
- build
- test
publish-help: publish-help:
stage: publish-help stage: publish-help
image: docker:latest tags:
services: - docker
- name: docker:dind
alias: docker
script: script:
- export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//') - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//')
- cd docs - cd docs
@@ -18,3 +71,5 @@ publish-help:
- docker push registry.leon-hoppe.de/leon.hoppe/hopframe:latest - docker push registry.leon-hoppe.de/leon.hoppe/hopframe:latest
only: only:
- tags - tags
dependencies:
- publish

View File

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

View File

@@ -12,9 +12,9 @@
</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$/.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/Services/Implementations/ContextExplorer.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/Writerside/topics/Plugins.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/Writerside/topics/Plugins.md" 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" />
</list> </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" />
@@ -34,10 +34,11 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="feature/virtual-properties" /> <entry key="$PROJECT_DIR$" value="dev" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="RESET_MODE" value="HARD" />
</component> </component>
<component name="GitLabMergeRequestFiltersHistory">{ <component name="GitLabMergeRequestFiltersHistory">{
&quot;lastFilter&quot;: { &quot;lastFilter&quot;: {
@@ -52,7 +53,7 @@
<component name="GitLabMergeRequestsSettings">{ <component name="GitLabMergeRequestsSettings">{
&quot;selectedUrlAndAccountId&quot;: { &quot;selectedUrlAndAccountId&quot;: {
&quot;first&quot;: &quot;https://git.leon-hoppe.de/leon.hoppe/hopframe.git&quot;, &quot;first&quot;: &quot;https://git.leon-hoppe.de/leon.hoppe/hopframe.git&quot;,
&quot;second&quot;: &quot;2d65fdcb-5f13-45ad-a7ba-91dd4a88d6e4&quot; &quot;second&quot;: &quot;f58c9371-9f54-454e-a0db-5b4bc1187bad&quot;
} }
}</component> }</component>
<component name="HighlightingSettingsPerFile"> <component name="HighlightingSettingsPerFile">
@@ -63,6 +64,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/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/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/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/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/1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669/List.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/24dd1164ba47541cb1d3eb011e638e16953dbea3ae3f4dc208c3bbf3e96298a/ServiceCollectionServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/24dd1164ba47541cb1d3eb011e638e16953dbea3ae3f4dc208c3bbf3e96298a/ServiceCollectionServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" />
@@ -80,6 +82,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/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/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/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/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/8d5d6cbff46ddc7b152381f92ae1ae51d3e7b57b14dd23840a11f5aaaaed396/InternalEntityEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a882d183338544fdbcbdfc7b6d3dcb78916630765551644a221b5be9c45a121b/Int32.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a882d183338544fdbcbdfc7b6d3dcb78916630765551644a221b5be9c45a121b/Int32.cs" root0="FORCE_HIGHLIGHTING" />
@@ -111,7 +114,7 @@
&quot;associatedIndex&quot;: 3 &quot;associatedIndex&quot;: 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>
@@ -128,9 +131,10 @@
&quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;, &quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;, &quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;,
&quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;, &quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;,
&quot;git-widget-placeholder&quot;: &quot;!33 on feature/exporters&quot;, &quot;git-widget-placeholder&quot;: &quot;dev&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&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.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
@@ -146,13 +150,14 @@
<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" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" /> <option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -161,13 +166,14 @@
<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" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" /> <option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -176,13 +182,14 @@
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj" /> <option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" /> <option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -191,13 +198,14 @@
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj" /> <option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" /> <option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -258,150 +266,21 @@
<workItem from="1740736919561" duration="191000" /> <workItem from="1740736919561" duration="191000" />
<workItem from="1740738257628" duration="3216000" /> <workItem from="1740738257628" duration="3216000" />
<workItem from="1740741585276" duration="17000" /> <workItem from="1740741585276" duration="17000" />
</task> <workItem from="1740742098571" duration="78000" />
<task id="LOCAL-00001" summary="Added basic configuration"> <workItem from="1740742471317" duration="672000" />
<option name="closed" value="true" /> <workItem from="1741974241977" duration="10854000" />
<created>1736850899254</created> <workItem from="1742038098473" duration="990000" />
<option name="number" value="00001" /> <workItem from="1742059898156" duration="3488000" />
<option name="presentableId" value="LOCAL-00001" /> <workItem from="1744725284649" duration="60000" />
<option name="project" value="LOCAL" /> <workItem from="1744916016381" duration="66000" />
<updated>1736850899254</updated> <workItem from="1744916106166" duration="49000" />
</task> <workItem from="1744966207145" duration="5231000" />
<task id="LOCAL-00002" summary="Added admin page navigation"> <workItem from="1751713720880" duration="8243000" />
<option name="closed" value="true" /> <workItem from="1751741813788" duration="4623000" />
<created>1736855209077</created> <workItem from="1768753475773" duration="455000" />
<option name="number" value="00002" /> <workItem from="1768753946559" duration="690000" />
<option name="presentableId" value="LOCAL-00002" /> <workItem from="1768756619311" duration="94000" />
<option name="project" value="LOCAL" /> <workItem from="1768847832546" duration="758000" />
<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 id="LOCAL-00006" summary="Added reload button and animation">
<option name="closed" value="true" />
<created>1737023058093</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1737023058093</updated>
</task>
<task id="LOCAL-00007" summary="Added relation picker dialog">
<option name="closed" value="true" />
<created>1737035288104</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1737035288104</updated>
</task>
<task id="LOCAL-00008" summary="Added automatic relation mapping">
<option name="closed" value="true" />
<created>1737037853482</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1737037853482</updated>
</task>
<task id="LOCAL-00009" summary="Added property validation">
<option name="closed" value="true" />
<created>1737040612038</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1737040612038</updated>
</task>
<task id="LOCAL-00010" summary="Added creation/modification confirmation">
<option name="closed" value="true" />
<created>1737040946489</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1737040946489</updated>
</task>
<task id="LOCAL-00011" summary="Removed Template">
<option name="closed" value="true" />
<created>1737042229086</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1737042229086</updated>
</task>
<task id="LOCAL-00012" summary="Added policy validation, ordering and virtual listing properties">
<option name="closed" value="true" />
<created>1737055409534</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1737055409535</updated>
</task>
<task id="LOCAL-00013" summary="Added n -&gt; m relation support">
<option name="closed" value="true" />
<created>1737129518866</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1737129518866</updated>
</task>
<task id="LOCAL-00014" summary="Added text area support and DI support for modifier functions">
<option name="closed" value="true" />
<created>1737202192471</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1737202192471</updated>
</task>
<task id="LOCAL-00015" summary="Addressed all build warnings">
<option name="closed" value="true" />
<created>1737203441319</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1737203441319</updated>
</task>
<task id="LOCAL-00016" summary="Added documentation for the configurators and service extensions methods">
<option name="closed" value="true" />
<created>1737208088933</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1737208088933</updated>
</task>
<task id="LOCAL-00017" summary="Created tests for the core module">
<option name="closed" value="true" />
<created>1737212497960</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1737212497960</updated>
</task>
<task id="LOCAL-00018" summary="Added more tests">
<option name="closed" value="true" />
<created>1737285123218</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1737285123218</updated>
</task> </task>
<task id="LOCAL-00019" summary="Added web module tests"> <task id="LOCAL-00019" summary="Added web module tests">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -611,7 +490,191 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1740741334420</updated> <updated>1740741334420</updated>
</task> </task>
<option name="localTasksCounter" value="45" /> <task id="LOCAL-00045" summary="Patched CI">
<option name="closed" value="true" />
<created>1740742170465</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1740742170465</updated>
</task>
<task id="LOCAL-00046" summary="Prepared CI for v3.2.0">
<option name="closed" value="true" />
<created>1740742538991</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<option name="project" value="LOCAL" />
<updated>1740742538991</updated>
</task>
<task id="LOCAL-00047" summary="Removed unused dependency">
<option name="closed" value="true" />
<created>1740742606152</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1740742606152</updated>
</task>
<task id="LOCAL-00048" summary="Fixed directory in pipeline">
<option name="closed" value="true" />
<created>1740742749325</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1740742749325</updated>
</task>
<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>
<task id="LOCAL-00050" summary="Added support for custom repositories">
<option name="closed" value="true" />
<created>1741985203179</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1741985203179</updated>
</task>
<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>
<task id="LOCAL-00055" summary="Made search suggestions togglable">
<option name="closed" value="true" />
<created>1751721576913</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1751721576913</updated>
</task>
<task id="LOCAL-00056" summary="Updated test pipeline">
<option name="closed" value="true" />
<created>1751747279843</created>
<option name="number" value="00056" />
<option name="presentableId" value="LOCAL-00056" />
<option name="project" value="LOCAL" />
<updated>1751747279844</updated>
</task>
<task id="LOCAL-00057" summary="Fixed typo in .gitlab-ci.yml">
<option name="closed" value="true" />
<created>1751747347169</created>
<option name="number" value="00057" />
<option name="presentableId" value="LOCAL-00057" />
<option name="project" value="LOCAL" />
<updated>1751747347169</updated>
</task>
<task id="LOCAL-00058" summary="updated test job">
<option name="closed" value="true" />
<created>1751747836791</created>
<option name="number" value="00058" />
<option name="presentableId" value="LOCAL-00058" />
<option name="project" value="LOCAL" />
<updated>1751747836791</updated>
</task>
<task id="LOCAL-00059" summary="Combined coverage reports in test job">
<option name="closed" value="true" />
<created>1751748307061</created>
<option name="number" value="00059" />
<option name="presentableId" value="LOCAL-00059" />
<option name="project" value="LOCAL" />
<updated>1751748307061</updated>
</task>
<task id="LOCAL-00060" summary="combined test results">
<option name="closed" value="true" />
<created>1751748936783</created>
<option name="number" value="00060" />
<option name="presentableId" value="LOCAL-00060" />
<option name="project" value="LOCAL" />
<updated>1751748936783</updated>
</task>
<task id="LOCAL-00061" summary="added coverage to test job">
<option name="closed" value="true" />
<created>1751749638139</created>
<option name="number" value="00061" />
<option name="presentableId" value="LOCAL-00061" />
<option name="project" value="LOCAL" />
<updated>1751749638139</updated>
</task>
<task id="LOCAL-00062" summary="Updated coverage extraction">
<option name="closed" value="true" />
<created>1751750081208</created>
<option name="number" value="00062" />
<option name="presentableId" value="LOCAL-00062" />
<option name="project" value="LOCAL" />
<updated>1751750081208</updated>
</task>
<task id="LOCAL-00063" summary="fixed coverage percentage printing">
<option name="closed" value="true" />
<created>1751750341741</created>
<option name="number" value="00063" />
<option name="presentableId" value="LOCAL-00063" />
<option name="project" value="LOCAL" />
<updated>1751750341742</updated>
</task>
<task id="LOCAL-00064" summary="fixed echo cmd">
<option name="closed" value="true" />
<created>1751750495636</created>
<option name="number" value="00064" />
<option name="presentableId" value="LOCAL-00064" />
<option name="project" value="LOCAL" />
<updated>1751750495636</updated>
</task>
<task id="LOCAL-00065" summary="Code cleanup + new pipeline setup">
<option name="closed" value="true" />
<created>1768754560236</created>
<option name="number" value="00065" />
<option name="presentableId" value="LOCAL-00065" />
<option name="project" value="LOCAL" />
<updated>1768754560236</updated>
</task>
<task id="LOCAL-00066" summary="Fixed #38">
<option name="closed" value="true" />
<created>1768847968844</created>
<option name="number" value="00066" />
<option name="presentableId" value="LOCAL-00066" />
<option name="project" value="LOCAL" />
<updated>1768847968844</updated>
</task>
<task id="LOCAL-00067" summary="Fixed #37">
<option name="closed" value="true" />
<created>1768848267710</created>
<option name="number" value="00067" />
<option name="presentableId" value="LOCAL-00067" />
<option name="project" value="LOCAL" />
<updated>1768848267710</updated>
</task>
<option name="localTasksCounter" value="68" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -621,38 +684,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 />
@@ -660,33 +702,44 @@
</component> </component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" /> <component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" /> <component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" /> <option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Tested login functionality" />
<MESSAGE value="prepared project for release" />
<MESSAGE value="Included readme file in projects" />
<MESSAGE value="Added missing files" />
<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" />
<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 deferred entry manipulation" />
<MESSAGE value="Removed select all button" />
<MESSAGE value="Added missing installation instructions" />
<MESSAGE value="Added modular event system" />
<MESSAGE value="Fixed event emitter service scope" />
<MESSAGE value="Added custom views" />
<MESSAGE value="Added plugin events" />
<MESSAGE value="Passed cancellation tokens to event handlers if needed" />
<MESSAGE value="Added plugin buttons" />
<MESSAGE value="Added default button removal feature" />
<MESSAGE value="Added custom search functionality" />
<MESSAGE value="Added fully virtual properties" />
<MESSAGE value="Added basic export and import feature" /> <MESSAGE value="Added basic export and import feature" />
<MESSAGE value="Finished converter plugin" /> <MESSAGE value="Finished converter plugin" />
<option name="LAST_COMMIT_MESSAGE" value="Finished converter plugin" /> <MESSAGE value="Patched CI" />
<MESSAGE value="Prepared CI for v3.2.0" />
<MESSAGE value="Removed unused dependency" />
<MESSAGE value="Fixed directory in pipeline" />
<MESSAGE value="Reverted pipeline to include all jobs" />
<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" />
<MESSAGE value="Made search suggestions togglable" />
<MESSAGE value="Updated test pipeline" />
<MESSAGE value="Fixed typo in .gitlab-ci.yml" />
<MESSAGE value="updated test job" />
<MESSAGE value="Combined coverage reports in test job" />
<MESSAGE value="combined test results" />
<MESSAGE value="added coverage to test job" />
<MESSAGE value="Updated coverage extraction" />
<MESSAGE value="fixed coverage percentage printing" />
<MESSAGE value="fixed echo cmd" />
<MESSAGE value="Code cleanup + new pipeline setup" />
<MESSAGE value="Fixed #38" />
<MESSAGE value="Fixed #37" />
<option name="LAST_COMMIT_MESSAGE" value="Fixed #37" />
</component> </component>
</project> </project>

View File

@@ -17,6 +17,7 @@
<toc-element topic="PropertyConfig.md"/> <toc-element topic="PropertyConfig.md"/>
</toc-element> </toc-element>
<toc-element topic="Callbacks.md"/> <toc-element topic="Callbacks.md"/>
<toc-element topic="Custom-Repositories.md"/>
</toc-element> </toc-element>
<toc-element toc-title="Web Module"> <toc-element toc-title="Web Module">
<toc-element toc-title="Interface"> <toc-element toc-title="Interface">
@@ -27,6 +28,10 @@
<toc-element topic="Plugins.md"> <toc-element topic="Plugins.md">
<toc-element topic="Events.md"> <toc-element topic="Events.md">
</toc-element> </toc-element>
<toc-element topic="Exporter-Plugin.md"/>
</toc-element> </toc-element>
</toc-element> </toc-element>
<toc-element toc-title="Services">
<toc-element topic="IFileService.md"/>
</toc-element>
</instance-profile> </instance-profile>

View File

@@ -0,0 +1,132 @@
# Custom Repositories
Custom repositories in HopFrame allow you to define and integrate custom logic for managing database entities. By implementing the `IHopFrameRepository<TModel, TKey>` interface, you can gain full control over how data is retrieved, modified, and managed. This feature is ideal for scenarios where the default behavior does not meet specific business requirements.
## IHopFrameRepository<TModel, TKey> Interface
The `IHopFrameRepository<TModel, TKey>` interface defines a contract for a repository that works with a specific model (`TModel`) and its primary key (`TKey`). The interface provides the following methods:
- **LoadPage**
Loads a paginated set of items.
```c#
Task<IEnumerable<TModel>> LoadPage(int page, int perPage);
```
- **Parameters:**
- `page`: The page number to load.
- `perPage`: The number of items per page.
- **Returns:** A collection of items for the specified page.
- **Search**
Performs a search query on the repository.
```c#
Task<SearchResult<TModel>> Search(string searchTerm, int page, int perPage);
```
- **Parameters:**
- `searchTerm`: The term to search for.
- `page`: The page number to load.
- `perPage`: The number of items per page.
- **Returns:** A `SearchResult` containing matching items and the total number of pages.
- **GetTotalPageCount**
Retrieves the total number of pages based on the items per page.
```c#
Task<int> GetTotalPageCount(int perPage);
```
- **Parameters:**
- `perPage`: The number of items per page.
- **Returns:** The total number of pages.
- **CreateItem**
Adds a new item to the repository.
```c#
Task CreateItem(TModel item);
```
- **Parameters:**
- `item`: The item to create.
- **EditItem**
Updates an existing item in the repository.
```c#
Task EditItem(TModel item);
```
- **Parameters:**
- `item`: The item to update.
- **DeleteItem**
Removes an item from the repository.
```c#
Task DeleteItem(TModel item);
```
- **Parameters:**
- `item`: The item to delete.
- **GetOne**
Retrieves a single item based on its primary key.
```c#
Task<TModel?> GetOne(TKey key);
```
- **Parameters:**
- `key`: The primary key of the item to retrieve.
- **Returns:** The item if found, or `null` if not.
## `SearchResult<TModel>` Struct
The `SearchResult<TModel>` struct is used to encapsulate the results of a search query.
- **Properties:**
- `Items`: The items retrieved from the search query.
- `PageCount`: The total number of pages based on the search results.
```c#
public readonly struct SearchResult<TModel>(IEnumerable<TModel> items, int pageCount) {
public IEnumerable<TModel> Items { get; init; }
public int PageCount { get; init; }
}
```
## Adding Custom Repositories
To add and configure a custom repository in HopFrame, use the `AddCustomRepository` methods. These methods allow you to specify a repository class (`TRepository`) implementing `IHopFrameRepository<TModel, TKey>` and define configurations for the associated table.
- **With Configurator**
```c#
HopFrameConfigurator AddCustomRepository<TRepository, TModel, TKey>(
Expression<Func<TModel, TKey>> keyExpression,
Action<TableConfigurator<TModel>> configurator
)
where TRepository : IHopFrameRepository<TModel, TKey>;
```
- **Parameters:**
- `keyExpression`: The key of the model.
- `configurator`: Configures the table page.
- **Without Configurator**
```c#
TableConfigurator<TModel> AddCustomRepository<TRepository, TModel, TKey>(
Expression<Func<TModel, TKey>> keyExpression
)
where TRepository : IHopFrameRepository<TModel, TKey>;
```
- **Parameters:**
- `keyExpression`: The key of the model.
- **Returns:** A `TableConfigurator` to configure the table.
By implementing custom repositories and using these methods, you can fully leverage the flexibility of HopFrame for your data management needs. Let me know if you'd like further elaboration!

View File

@@ -0,0 +1,51 @@
# Exporter Plugin
The Exporter Plugin is a tool for managing the import and export of data from the HopFrame UI. It provides functionality for exporting table data into a CSV file and importing data back into the system, making data manipulation and backups more seamless.
## What the Exporter Plugin Does
1. **Export Table Data to CSV**
- The plugin allows users to export all data from a table as a CSV file.
- The exported file includes all non-virtual properties as table headers.
- The export process dynamically constructs rows for each entry in the table.
2. **Import Data from CSV**
- Users can import a CSV file to populate or update a table.
- The import process reads the file, validates the headers, and creates new entries or updates existing ones.
- Relationships and enumerable properties are also resolved using the appropriate managers.
3. **User Interface Integration**
- Adds two buttons, "Export" and "Import," to the page header of each table.
- **Export Button:** Initiates the export functionality.
- **Import Button:** Allows users to upload a CSV file for import.
4. **Error Handling**
- Ensures errors during import or export (e.g., invalid file format, missing data, or system issues) are shown to the user as toast messages.
## Adding the Exporter Plugin
To include the Exporter Plugin in your HopFrame setup, use the `AddExporters` method provided by the `HopFrameConfiguratorExtensions`.
Heres how to register the Exporter Plugin in your application configuration:
```c#
builder.Services.AddHopFrame(options => {
options.AddExporters();
});
```
The `AddExporters` method internally registers the `ExporterPlugin` and attaches its functionality to the HopFrame.
## Key Features of the Export Process
- **Dynamic Header Creation:** Automatically generates headers based on the table's non-virtual properties.
- **Data Transformation:** Transforms property values into CSV-compatible formats.
- **File Download:** Saves the generated CSV file with the tables display name.
## Key Features of the Import Process
- **Header Validation:** Validates that the CSV file headers match the table's properties.
- **Type Conversion:** Converts values in the CSV file to their respective data types.
- **Relationship Management:** Resolves relationships and enumerable properties during import.
This plugin streamlines data operations, reducing manual effort and enabling quick data migration or updates. Let me know if youd like to dive deeper into any specific aspect!

View File

@@ -118,6 +118,50 @@ DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext :
- **Returns:** The configurator of the context if it already was defined, `null` if not. - **Returns:** The configurator of the context if it already was defined, `null` if not.
### AddCustomRepository (With configurator)
Adds a table of the desired type and configures it to use a custom repository.
```c#
HopFrameConfigurator AddCustomRepository<TRepository, TModel, TKey>(
Expression<Func<TModel, TKey>> keyExpression,
Action<TableConfigurator<TModel>> configurator
)
where TRepository : IHopFrameRepository<TModel, TKey>
```
- **Type Parameters:**
- `TRepository`: The repository class that inherits from `IHopFrameRepository<TModel, TKey>` (needs to be registered as a service).
- `TModel`: The model of the table.
- `TKey`: The type of the primary key.
- **Parameters:**
- `keyExpression`: The key of the model.
- `configurator`: The configurator used for configuring the table page.
- **Returns:** `HopFrameConfigurator`
### AddCustomRepository (Without configurator)
Adds a table of the desired type and configures it to use a custom repository.
```c#
TableConfigurator<TModel> AddCustomRepository<TRepository, TModel, TKey>(
Expression<Func<TModel, TKey>> keyExpression
)
where TRepository : IHopFrameRepository<TModel, TKey>
```
- **Type Parameters:**
- `TRepository`: The repository class that inherits from `IHopFrameRepository<TModel, TKey>` (needs to be registered as a service).
- `TModel`: The model of the table.
- `TKey`: The type of the primary key.
- **Parameters:**
- `keyExpression`: The key of the model.
- **Returns:** The configurator used for configuring the table page: `TableConfigurator<TModel>`.
### DisplayUserInfo ### DisplayUserInfo
Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin UI. Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin UI.

View File

@@ -0,0 +1,41 @@
# IFileService
The `IFileService` interface provides methods for handling file operations, such as downloading and uploading files within the HopFrame web application. It abstracts file-related operations to ensure a smooth and consistent user experience.
## Methods
1. **DownloadFile**
- Initiates the download of a file with the given name and data.
- Suitable for dynamically generating and offering files to the user, such as CSV exports or reports.
```c#
Task DownloadFile(string name, byte[] data);
```
- **Parameters:**
- `name`: The name of the file to be downloaded (including the extension, e.g., "example.csv").
- `data`: The byte array representing the content of the file.
- **Usage Example:** Exporting table data as a CSV file for download.
2. **UploadFile**
- Allows the user to upload a file through the web interface and returns the uploaded file for further processing.
- This method provides integration with Blazor's `IBrowserFile` for easy file handling.
```c#
Task<IBrowserFile> UploadFile();
```
- **Returns:** An `IBrowserFile` instance representing the uploaded file.
- **Usage Example:** Importing data from a CSV file to populate or update a table.
## Integration
The `IFileService` is commonly used in conjunction with plugins or components that require file operations, such as the Exporter Plugin, which leverages this service to enable data export and import functionality.
## Key Features
- Streamlines file handling for web applications.
- Simplifies both download and upload processes with minimal code.
- Ensures compatibility with Blazor's file-handling capabilities.
By implementing or extending the `IFileService`, developers can customize the file-handling behavior to suit specific application needs. Let me know if you'd like more examples or details!

View File

@@ -2,7 +2,13 @@
namespace HopFrame.Core.Config; 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 Type ContextType { get; }
public List<TableConfig> Tables { get; } = new(); public List<TableConfig> Tables { get; } = new();
public HopFrameConfig ParentConfig { get; } 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.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core.Config; namespace HopFrame.Core.Config;
public class HopFrameConfig { public class HopFrameConfig {
public List<DbContextConfig> Contexts { get; } = new(); public List<ITableGroupConfig> Contexts { get; } = new();
public bool DisplayUserInfo { get; set; } = true; public bool DisplayUserInfo { get; set; } = true;
public string? BasePolicy { get; set; } public string? BasePolicy { get; set; }
public string? LoginPageRewrite { get; set; } public string? LoginPageRewrite { get; set; }
@@ -48,6 +50,36 @@ public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollecti
return new DbContextConfigurator<TDbContext>(context); 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> /// <summary>
/// Check if a context is already registered in the HopFrame /// Check if a context is already registered in the HopFrame
/// </summary> /// </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> /// <returns>The configurator of the context if it already was defined, null if not</returns>
public DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext : DbContext { public DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext : DbContext {
var config = InnerConfig.Contexts var config = InnerConfig.Contexts
.OfType<DbContextConfig>()
.SingleOrDefault(context => context.ContextType == typeof(TDbContext)); .SingleOrDefault(context => context.ContextType == typeof(TDbContext));
if (config is null) return null; 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,10 +11,11 @@ public class TableConfig {
public string PropertyName { get; } public string PropertyName { get; }
public string DisplayName { get; set; } public string DisplayName { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public DbContextConfig ContextConfig { get; } public ITableGroupConfig ContextConfig { get; }
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; }
@@ -23,7 +24,7 @@ public class TableConfig {
public List<PropertyConfig> Properties { get; } = new(); 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; TableType = tableType;
PropertyName = propertyName; PropertyName = propertyName;
ContextConfig = config; ContextConfig = config;
@@ -62,10 +63,17 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines if the table should be ignored in the admin ui /// Determines if the table should be ignored in the admin ui
/// </summary> /// </summary>
public TableConfigurator<TModel> Ignore(bool ignore) { public TableConfigurator<TModel> Ignore(bool ignore = true) {
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

View File

@@ -9,6 +9,7 @@
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Core</PackageId> <PackageId>HopFrame.Core</PackageId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

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

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

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

View File

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

View File

@@ -18,7 +18,9 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
public TableConfig? GetTable(string tableDisplayName) { public TableConfig? GetTable(string tableDisplayName) {
foreach (var context in config.Contexts) { foreach (var context in config.Contexts) {
var table = context.Tables.FirstOrDefault(table => table.DisplayName.Equals(tableDisplayName, StringComparison.CurrentCultureIgnoreCase)); var table = context.Tables
.Where(t => !t.Ignored)
.FirstOrDefault(table => table.DisplayName.Equals(tableDisplayName, StringComparison.CurrentCultureIgnoreCase));
if (table is null) continue; if (table is null) continue;
SeedTableData(table); SeedTableData(table);
@@ -30,7 +32,8 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
public TableConfig? GetTable(Type tableEntity) { public TableConfig? GetTable(Type tableEntity) {
foreach (var context in config.Contexts) { foreach (var context in config.Contexts) {
var table = context.Tables.FirstOrDefault(table => table.TableType == tableEntity); var table = context.Tables
.FirstOrDefault(table => table.TableType == tableEntity);
if (table is null) continue; if (table is null) continue;
SeedTableData(table); SeedTableData(table);
@@ -45,11 +48,18 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName); var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName);
if (table is null) continue; if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext; var repo = provider.GetService(context.ContextType);
if (dbContext is null) return null; if (repo is null) return null;
var type = typeof(TableManager<>).MakeGenericType(table.TableType); if (context is DbContextConfig) {
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
} }
return null; return null;
@@ -60,11 +70,18 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.TableType == tableType); var table = context.Tables.FirstOrDefault(table => table.TableType == tableType);
if (table is null) continue; if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext; var repo = provider.GetService(context.ContextType);
if (dbContext is null) return null; if (repo is null) return null;
var type = typeof(TableManager<>).MakeGenericType(table.TableType); if (context is DbContextConfig) {
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
} }
return null; return null;
@@ -72,6 +89,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
private void SeedTableData(TableConfig table) { private void SeedTableData(TableConfig table) {
if (table.Seeded) return; if (table.Seeded) return;
if (table.ContextConfig is not DbContextConfig) return;
var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!; var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!;
var entity = dbContext.Model.FindEntityType(table.TableType)!; 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, ISearchExpressionBuilder searchExpressionBuilder) : 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, searchExpressionBuilder);
return await manager.DisplayProperty(item, prop, value, enumerableValue);
}
}

View File

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

View File

@@ -1,33 +1,48 @@
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 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 table = context.Set<TModel>();
var data = IncludeForeignKeys(table); var data = IncludeForeignKeys(table);
return data return await data
.Skip(page * perPage) .Skip(page * perPage)
.Take(perPage); .Take(perPage)
.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);
@@ -60,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;

View File

@@ -39,11 +39,11 @@
</div> </div>
<div style="display: flex; gap: 5px; margin-bottom: 4px"> <div style="display: flex; gap: 5px; margin-bottom: 4px">
@if (!property.IsRequired) { @if (!property.IsRequired) {
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Relation)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await SetPropertyValue(property, null, InputType.Relation))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
} }
<FluentButton OnClick="async () => await OpenRelationalPicker(property)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await OpenRelationalPicker(property))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -131,7 +131,7 @@
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" /> ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
</div> </div>
<div style="display: flex; gap: 5px"> <div style="display: flex; gap: 5px">
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Enum)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await SetPropertyValue(property, null, InputType.Enum))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -340,7 +340,7 @@
var relationType = config.Info.PropertyType; var relationType = config.Info.PropertyType;
if (config.IsEnumerable) { if (config.IsEnumerable) {
relationType = config.Info.PropertyType.GetGenericArguments().First(); relationType = relationType.GetGenericArguments().First();
} }
var relationTable = Explorer.GetTable(relationType); var relationTable = Explorer.GetTable(relationType);

View File

@@ -9,6 +9,7 @@
DisplayActions="false" DisplayActions="false"
DisplaySelection="true" DisplaySelection="true"
TableDisplayName="@Content.SourceTable.DisplayName" TableDisplayName="@Content.SourceTable.DisplayName"
TableType="@Content.SourceTable.TableType"
PerPage="15" PerPage="15"
DialogData="Content" DialogData="Content"
SelectionMode="@(Content.AllowMultiple ? DataGridSelectMode.Multiple : DataGridSelectMode.Single)"/> SelectionMode="@(Content.AllowMultiple ? DataGridSelectMode.Multiple : DataGridSelectMode.Single)"/>

View File

@@ -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>
@@ -24,7 +24,7 @@
@if (!DisplaySelection && _buttonToggles.ShowRefreshButton) { @if (!DisplaySelection && _buttonToggles.ShowRefreshButton) {
<FluentButton <FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())" IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload" OnClick="@(Reload)"
Loading="_loading" Loading="_loading"
Style="margin-left: 10px"> Style="margin-left: 10px">
Refresh Refresh
@@ -34,22 +34,43 @@
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopLeft)) { @foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopLeft)) {
<FluentButton <FluentButton
IconStart="@(button.Icon?.GetInstance())" IconStart="@(button.Icon?.GetInstance())"
OnClick="() => button.Handler.Invoke(null!, _config!)"> OnClick="@(() => button.Handler.Invoke(null!, _config!))">
@button.Title @button.Title
</FluentButton> </FluentButton>
} }
<FluentSpacer /> <FluentSpacer />
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" /> <div
style="position: relative; height: 32px"
class="hopframe-search">
@* ReSharper disable once CSharpWarnings::CS4014 *@
<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>
} }
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopRight)) { @foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopRight)) {
<FluentButton <FluentButton
IconStart="@(button.Icon?.GetInstance())" IconStart="@(button.Icon?.GetInstance())"
OnClick="() => button.Handler.Invoke(null!, _config!)"> OnClick="@(() => button.Handler.Invoke(null!, _config!))">
@button.Title @button.Title
</FluentButton> </FluentButton>
} }
@@ -64,7 +85,7 @@
TGridItem="object" TGridItem="object"
SelectMode="SelectionMode" SelectMode="SelectionMode"
SelectFromEntireRow="true" SelectFromEntireRow="true"
OnSelect="data => SelectItem(data.Item, data.Selected)" OnSelect="@(data => SelectItem(data.Item, data.Selected))"
SelectAllDisabled="true" SelectAllDisabled="true"
Property="o => DialogData!.SelectedObjects.Contains(o)" Property="o => DialogData!.SelectedObjects.Contains(o)"
Style="min-width: max-content; height: 44px; display: grid; align-items: center" /> Style="min-width: max-content; height: 44px; display: grid; align-items: center" />
@@ -80,19 +101,19 @@
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) { @if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content"> <TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) { @foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) {
<FluentButton OnClick="() => button.Handler.Invoke(context, _config!)"> <FluentButton OnClick="@(() => button.Handler.Invoke(context, _config!))">
<FluentIcon Value="@(button.Icon!.GetInstance())" /> <FluentIcon Value="@(button.Icon!.GetInstance())" />
</FluentButton> </FluentButton>
} }
@if (_hasUpdatePolicy && _buttonToggles.ShowEditButton) { @if (_hasUpdatePolicy && _buttonToggles.ShowEditButton) {
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(context); }"> <FluentButton aria-label="Edit entry" OnClick="@(async () => { await CreateOrEdit(context); })">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/> <FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
</FluentButton> </FluentButton>
} }
@if (_hasDeletePolicy && _buttonToggles.ShowDeleteButton) { @if (_hasDeletePolicy && _buttonToggles.ShowDeleteButton) {
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(context); }"> <FluentButton aria-label="Delete entry" OnClick="@(async () => { await DeleteEntry(context); })">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/> <FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
</FluentButton> </FluentButton>
} }
@@ -104,7 +125,7 @@
@if (_totalPages > 1) { @if (_totalPages > 1) {
<div class="hopframe-paginator"> <div class="hopframe-paginator">
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)"> <FluentButton BackgroundColor="transparent" OnClick="@(async () => await ChangePage(_currentPage - 1))">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
@@ -114,12 +135,12 @@
Items="Enumerable.Range(0, _totalPages)" Items="Enumerable.Range(0, _totalPages)"
OptionValue="@(p => p.ToString())" OptionValue="@(p => p.ToString())"
OptionText="@(p => (p + 1).ToString())" OptionText="@(p => (p + 1).ToString())"
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))" ValueChanged="@(async s => await ChangePage(Convert.ToInt32(s)))"
Width="max-content" SelectedOption="@_currentPage"/> Width="max-content" SelectedOption="@_currentPage"/>
<span>of @_totalPages</span> <span>of @_totalPages</span>
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)"> <FluentButton BackgroundColor="transparent" OnClick="@(async () => await ChangePage(_currentPage + 1))">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -153,7 +174,7 @@
<FluentToastProvider MaxToastCount="10" /> <FluentToastProvider MaxToastCount="10" />
<InputFile style="display: none" @ref="FileInputElement" OnChange="OnInputFiles"></InputFile> <InputFile style="display: none" @ref="FileInputElement" OnChange="@(OnInputFiles)"></InputFile>
@inject IContextExplorer Explorer @inject IContextExplorer Explorer
@inject NavigationManager Navigator @inject NavigationManager Navigator
@@ -162,11 +183,15 @@
@inject IHopFrameAuthHandler Handler @inject IHopFrameAuthHandler Handler
@inject ICallbackEmitter Emitter @inject ICallbackEmitter Emitter
@inject IPluginOrchestrator PluginOrchestrator @inject IPluginOrchestrator PluginOrchestrator
@inject ISearchSuggestionProvider SearchSuggestions
@code { @code {
[Parameter]
public string TableDisplayName { get; set; } = null!;
[Parameter] [Parameter]
public required string TableDisplayName { get; set; } public Type? TableType { get; set; }
[Parameter] [Parameter]
public bool DisplaySelection { get; set; } public bool DisplaySelection { get; set; }
@@ -191,13 +216,14 @@
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;
private bool _hasCreatePolicy; private bool _hasCreatePolicy;
private bool _allSelected;
private readonly CancellationTokenSource _tokenSource = new(); private readonly CancellationTokenSource _tokenSource = new();
private List<PluginButton> _pluginButtons = new(); private List<PluginButton> _pluginButtons = new();
private DefaultButtonToggles _buttonToggles = new(); private DefaultButtonToggles _buttonToggles = new();
@@ -206,6 +232,11 @@
protected override void OnInitialized() { protected override void OnInitialized() {
CurrentInstance = this; CurrentInstance = this;
if (TableType is not null) {
_config ??= Explorer.GetTable(TableType);
}
_config ??= Explorer.GetTable(TableDisplayName); _config ??= Explorer.GetTable(TableDisplayName);
if (_config is null || (_config.Ignored && DialogData is null)) { if (_config is null || (_config.Ignored && DialogData is null)) {
@@ -231,7 +262,7 @@
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy); _hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
_manager ??= Explorer.GetTableManager(_config!.PropertyName); _manager ??= Explorer.GetTableManager(_config!.PropertyName);
CurrentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync(); CurrentlyDisplayedModels = (await _manager!.LoadPage(_currentPage, PerPage)).ToArray();
_totalPages = await _manager.TotalPages(PerPage); _totalPages = await _manager.TotalPages(PerPage);
} }
@@ -255,6 +286,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 +306,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;
@@ -388,14 +450,6 @@
else DialogData!.SelectedObjects.Add(item); else DialogData!.SelectedObjects.Add(item);
} }
private void SelectAll() {
var selected = CurrentlyDisplayedModels.All(DialogData!.SelectedObjects.Contains);
foreach (var displayedModel in CurrentlyDisplayedModels) {
SelectItem(displayedModel, !selected);
}
_allSelected = selected;
}
private async Task<string> DisplayProperty(PropertyConfig config, object entry) { private async Task<string> DisplayProperty(PropertyConfig config, object entry) {
var display = await _manager!.DisplayProperty(entry, config); var display = await _manager!.DisplayProperty(entry, config);
@@ -421,4 +475,5 @@
public void RequestRender() { public void RequestRender() {
StateHasChanged(); StateHasChanged();
} }
} }

View File

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

View File

@@ -9,6 +9,7 @@
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageId>HopFrame.Web</PackageId> <PackageId>HopFrame.Web</PackageId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -56,6 +56,10 @@ public static class HopFrameConfiguratorExtensions {
return configurator; return configurator;
} }
/// <summary>
/// Registers the Exporter Plugin for data import/export functionality.
/// </summary>
/// <param name="configurator">The configurator for the HopFrame configuration.</param>
public static HopFrameConfigurator AddExporters(this HopFrameConfigurator configurator) { public static HopFrameConfigurator AddExporters(this HopFrameConfigurator configurator) {
configurator.AddPlugin<ExporterPlugin>(); configurator.AddPlugin<ExporterPlugin>();
return configurator; return configurator;

View File

@@ -17,6 +17,8 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
[EventHandler] [EventHandler]
public void OnInit(TableInitializedEvent e) { 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("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()); 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 var data = await manager
.LoadPage(0, int.MaxValue) .LoadPage(0, int.MaxValue);
.ToArrayAsync();
var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray(); 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; if (property is null) continue;
object? value = rowValues[i]; object? value = rowValues[i];
if (string.IsNullOrWhiteSpace((string)value)) continue;
if (property.IsEnumerable) { if (property.IsEnumerable) {
if (!property.Info.PropertyType.IsGenericType) continue; 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); var enumerable = Activator.CreateInstance(property.Info.PropertyType);
foreach (var key in values) { 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; if (entry is null) continue;
addMethod.Invoke(enumerable, [entry]); addMethod.Invoke(enumerable, [entry]);
@@ -116,7 +118,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
var relationManager = explorer.GetTableManager(property.Info.PropertyType); var relationManager = explorer.GetTableManager(property.Info.PropertyType);
var relationPrimaryKeyType = GetPrimaryKeyType(property.Info.PropertyType); var relationPrimaryKeyType = GetPrimaryKeyType(property.Info.PropertyType);
if (relationManager is null || relationPrimaryKeyType is null) continue; 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)) { else if (property.Info.PropertyType == typeof(Guid)) {
var success = Guid.TryParse((string)value, out var 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) { if (property.IsEnumerable) {
var enumerable = (IEnumerable)value; 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 return entity
.GetType() .GetType()
.GetProperties() .GetProperties()
@@ -169,6 +178,11 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
} }
private Type? GetPrimaryKeyType(Type tableType) { private Type? GetPrimaryKeyType(Type tableType) {
var table = explorer.GetTable(tableType);
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.PropertyType;
}
return tableType return tableType
.GetProperties() .GetProperties()
.FirstOrDefault(prop => prop .FirstOrDefault(prop => prop

View File

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

View File

@@ -2,10 +2,22 @@
namespace HopFrame.Web.Services; namespace HopFrame.Web.Services;
/// <summary>
/// Provides file handling capabilities for downloading and uploading files.
/// </summary>
public interface IFileService { public interface IFileService {
/// <summary>
/// Initiates a file download with the specified name and data.
/// </summary>
/// <param name="name">The name of the file to be downloaded.</param>
/// <param name="data">The byte array representing the file's content.</param>
public Task DownloadFile(string name, byte[] data); public Task DownloadFile(string name, byte[] data);
/// <summary>
/// Allows the user to upload a file and returns the uploaded file for processing.
/// </summary>
/// <returns>A task that returns an IBrowserFile representing the uploaded file.</returns>
public Task<IBrowserFile> UploadFile(); public Task<IBrowserFile> UploadFile();
} }

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

View File

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

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

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

View File

@@ -4,6 +4,7 @@ using HopFrame.Testing.Components;
using HopFrame.Testing.Models; using HopFrame.Testing.Models;
using HopFrame.Web; using HopFrame.Web;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Message = HopFrame.Testing.Models.Message;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -50,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>()
@@ -88,8 +93,31 @@ builder.Services.AddHopFrame(options => {
.SetPolicy("counter.view"); .SetPolicy("counter.view");
options.AddExporters(); 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(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.

View File

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

View File

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

View File

@@ -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);
@@ -40,7 +43,7 @@ public class TableManagerTests {
} }
[Fact] [Fact]
public void LoadPage_ReturnsPagedData() { public async Task LoadPage_ReturnsPagedData() {
// Arrange // Arrange
var data = new List<MockModel> { var data = new List<MockModel> {
new MockModel { Id = 1, Name = "Item1" }, new MockModel { Id = 1, Name = "Item1" },
@@ -51,42 +54,16 @@ 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 = manager.LoadPage(1, 2).ToList(); var result = (await manager.LoadPage(1, 2)).ToArray();
// Assert // Assert
Assert.Single(result); Assert.Single(result);
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

View File

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