16 Commits

Author SHA1 Message Date
b27800bfdf Added more descriptive inline documentations 2026-01-23 22:11:35 +01:00
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
49 changed files with 1614 additions and 366 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

@@ -13,8 +13,21 @@
<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$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" 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.Core/Config/HopFrameConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/HopFrameConfig.cs" 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$/src/HopFrame.Core/Config/PropertyConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/PropertyConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Config/TableConfig.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/Repositories/IHopFrameRepository.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/Repositories/IHopFrameRepository.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Annotations/EventHandlerAttribute.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Annotations/EventHandlerAttribute.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/EntryEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/EntryEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/SearchEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/SearchEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/TableInitializedEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/TableInitializedEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/ValidationEvent.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Plugins/Events/ValidationEvent.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/ServiceCollectionExtensions.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Services/ISearchSuggestionProvider.cs" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Services/ISearchSuggestionProvider.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 +47,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 +66,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 +77,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 +95,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 +127,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 +144,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 +163,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 +179,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 +195,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 +211,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,158 +279,22 @@
<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="1238000" />
<updated>1736855209077</updated> <workItem from="1769196883500" duration="5779000" />
</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 id="LOCAL-00019" summary="Added web module tests">
<option name="closed" value="true" />
<created>1737298835225</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1737298835225</updated>
</task> </task>
<task id="LOCAL-00020" summary="Tested login functionality"> <task id="LOCAL-00020" summary="Tested login functionality">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -611,7 +496,199 @@
<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>
<task id="LOCAL-00068" summary="Added ability to use ignored tables in relation picker">
<option name="closed" value="true" />
<created>1768848702981</created>
<option name="number" value="00068" />
<option name="presentableId" value="LOCAL-00068" />
<option name="project" value="LOCAL" />
<updated>1768848702981</updated>
</task>
<option name="localTasksCounter" value="69" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -621,38 +698,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 +716,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="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" />
<MESSAGE value="Added ability to use ignored tables in relation picker" />
<option name="LAST_COMMIT_MESSAGE" value="Added ability to use ignored tables in relation picker" />
</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; }
@@ -22,6 +24,10 @@ public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollecti
/// </summary> /// </summary>
public HopFrameConfig InnerConfig { get; } = config; public HopFrameConfig InnerConfig { get; } = config;
/// <summary>
/// The <see cref="ServiceCollection"/> of the application.
/// WARNING: Only use this during application building phase
/// </summary>
public IServiceCollection ServiceCollection { get; } = collection; public IServiceCollection ServiceCollection { get; } = collection;
/// <summary> /// <summary>
@@ -48,6 +54,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 +100,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

@@ -36,7 +36,9 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert
} }
} }
/// <inheritdoc />
public sealed class VirtualPropertyConfig(TableConfig table, int nthProperty) : PropertyConfig(GetDummyProperty(), table, nthProperty) { public sealed class VirtualPropertyConfig(TableConfig table, int nthProperty) : PropertyConfig(GetDummyProperty(), table, nthProperty) {
public string? DummyProperty { get; set; } = null; public string? DummyProperty { get; set; } = null;
public Func<object, string, IServiceProvider, Task>? VirtualParser { get; set; } public Func<object, string, IServiceProvider, Task>? VirtualParser { get; set; }
@@ -69,6 +71,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Sets the title displayed in the table header and edit dialog /// Sets the title displayed in the table header and edit dialog
/// </summary> /// </summary>
/// <param name="displayName">The new name of the property</param>
public PropertyConfigurator<TProp> SetDisplayName(string displayName) { public PropertyConfigurator<TProp> SetDisplayName(string displayName) {
InnerConfig.Name = displayName; InnerConfig.Name = displayName;
return this; return this;
@@ -77,6 +80,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the property should appear in the table, if not the property is also set to be not searchable /// Determines if the property should appear in the table, if not the property is also set to be not searchable
/// </summary> /// </summary>
/// <param name="list">The toggle for the option</param>
/// <seealso cref="IsSearchable"/> /// <seealso cref="IsSearchable"/>
public PropertyConfigurator<TProp> List(bool list) { public PropertyConfigurator<TProp> List(bool list) {
InnerConfig.List = list; InnerConfig.List = list;
@@ -87,6 +91,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the table can be sorted by the property /// Determines if the table can be sorted by the property
/// </summary> /// </summary>
/// <param name="sortable">The toggle for the option</param>
public PropertyConfigurator<TProp> IsSortable(bool sortable) { public PropertyConfigurator<TProp> IsSortable(bool sortable) {
InnerConfig.Sortable = sortable; InnerConfig.Sortable = sortable;
return this; return this;
@@ -95,6 +100,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the property get taken into account for search results /// Determines if the property get taken into account for search results
/// </summary> /// </summary>
/// <param name="searchable">The toggle for the option</param>
public PropertyConfigurator<TProp> IsSearchable(bool searchable) { public PropertyConfigurator<TProp> IsSearchable(bool searchable) {
InnerConfig.Searchable = searchable; InnerConfig.Searchable = searchable;
return this; return this;
@@ -103,6 +109,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the value that should be displayed instead of the string representation of the type /// Determines if the value that should be displayed instead of the string representation of the type
/// </summary> /// </summary>
/// <param name="propertyExpression">The expression that points at the property that should be used</param>
public PropertyConfigurator<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) { public PropertyConfigurator<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) {
InnerConfig.DisplayedProperty = TableConfigurator<TProp>.GetPropertyInfo(propertyExpression); InnerConfig.DisplayedProperty = TableConfigurator<TProp>.GetPropertyInfo(propertyExpression);
return this; return this;
@@ -111,6 +118,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the value that's displayed in the admin ui /// Determines the value that's displayed in the admin ui
/// </summary> /// </summary>
/// <param name="formatter">The function that formats the given entity</param>
/// <seealso cref="SetDisplayedProperty{TInnerProp}"/> /// <seealso cref="SetDisplayedProperty{TInnerProp}"/>
public PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, string> formatter) { public PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, string> formatter) {
InnerConfig.Formatter = (obj, provider) => Task.FromResult(formatter.Invoke((TProp)obj, provider)); InnerConfig.Formatter = (obj, provider) => Task.FromResult(formatter.Invoke((TProp)obj, provider));
@@ -126,6 +134,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the value that's displayed for each entry in the list /// Determines the value that's displayed for each entry in the list
/// </summary> /// </summary>
/// <param name="formatter">The function that formats each given element</param>
public PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) { public PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) {
InnerConfig.EnumerableFormatter = (obj, provider) => Task.FromResult(formatter.Invoke((TInnerProp)obj, provider)); InnerConfig.EnumerableFormatter = (obj, provider) => Task.FromResult(formatter.Invoke((TInnerProp)obj, provider));
return this; return this;
@@ -140,6 +149,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the function used for parsing the value provided in the editor dialog to the actual property value /// Determines the function used for parsing the value provided in the editor dialog to the actual property value
/// </summary> /// </summary>
/// <param name="parser">The function that converts the user input to the desired type</param>
public PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) { public PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) {
InnerConfig.Parser = (str, provider) => Task.FromResult<object>(parser.Invoke(str, provider)!); InnerConfig.Parser = (str, provider) => Task.FromResult<object>(parser.Invoke(str, provider)!);
return this; return this;
@@ -154,6 +164,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the value can be edited in the admin ui. If true, the value can still be initially set, but not modified /// Determines if the value can be edited in the admin ui. If true, the value can still be initially set, but not modified
/// </summary> /// </summary>
/// <param name="editable">The toggle for the option</param>
/// <seealso cref="SetCreatable"/> /// <seealso cref="SetCreatable"/>
public PropertyConfigurator<TProp> SetEditable(bool editable) { public PropertyConfigurator<TProp> SetEditable(bool editable) {
InnerConfig.Editable = editable; InnerConfig.Editable = editable;
@@ -163,6 +174,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the initial value can be edited in the admin ui. If true the value will not be visible in the create dialog /// Determines if the initial value can be edited in the admin ui. If true the value will not be visible in the create dialog
/// </summary> /// </summary>
/// <param name="creatable">The toggle for the option</param>
/// <seealso cref="SetEditable"/> /// <seealso cref="SetEditable"/>
public PropertyConfigurator<TProp> SetCreatable(bool creatable) { public PropertyConfigurator<TProp> SetCreatable(bool creatable) {
InnerConfig.Creatable = creatable; InnerConfig.Creatable = creatable;
@@ -172,6 +184,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the value should be displayed in the admin ui (useful for secrets like passwords etc.) /// Determines if the value should be displayed in the admin ui (useful for secrets like passwords etc.)
/// </summary> /// </summary>
/// <param name="display">The toggle for the option</param>
public PropertyConfigurator<TProp> DisplayValue(bool display) { public PropertyConfigurator<TProp> DisplayValue(bool display) {
InnerConfig.DisplayValue = display; InnerConfig.DisplayValue = display;
return this; return this;
@@ -180,6 +193,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the admin ui should use a text area for modifying the value /// Determines if the admin ui should use a text area for modifying the value
/// </summary> /// </summary>
/// <param name="textField">The toggle for the option</param>
/// <seealso cref="SetTextAreaRows"/> /// <seealso cref="SetTextAreaRows"/>
public PropertyConfigurator<TProp> IsTextArea(bool textField) { public PropertyConfigurator<TProp> IsTextArea(bool textField) {
InnerConfig.TextArea = textField; InnerConfig.TextArea = textField;
@@ -189,6 +203,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the initial size of the text area field /// Determines the initial size of the text area field
/// </summary> /// </summary>
/// <param name="rows">The number of rows (height) the text area field should have</param>
/// <seealso cref="IsTextArea"/> /// <seealso cref="IsTextArea"/>
public PropertyConfigurator<TProp> SetTextAreaRows(int rows) { public PropertyConfigurator<TProp> SetTextAreaRows(int rows) {
InnerConfig.TextAreaRows = rows; InnerConfig.TextAreaRows = rows;
@@ -198,14 +213,18 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the validator used for the property value before saving /// Determines the validator used for the property value before saving
/// </summary> /// </summary>
/// <param name="validator">
/// The function that validates the given input.
///
/// It takes in the parsed property and an <see cref="IServiceProvider"/>
/// and returns an error list. If the list is empty, the property passes the check.
/// </param>
public PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, IEnumerable<string>> validator) { public PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, IEnumerable<string>> validator) {
InnerConfig.Validator = (obj, provider) => Task.FromResult(validator.Invoke((TProp?)obj, provider)); InnerConfig.Validator = (obj, provider) => Task.FromResult(validator.Invoke((TProp?)obj, provider));
return this; return this;
} }
/// <summary> /// <inheritdoc cref="SetValidator(System.Func{TProp?,System.IServiceProvider,System.Collections.Generic.IEnumerable{string}})"/>
/// Determines the validator used for the property value before saving
/// </summary>
public PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, Task<IEnumerable<string>>> validator) { public PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, Task<IEnumerable<string>>> validator) {
InnerConfig.Validator = (obj, provider) => validator.Invoke((TProp?)obj, provider); InnerConfig.Validator = (obj, provider) => validator.Invoke((TProp?)obj, provider);
return this; return this;
@@ -214,6 +233,7 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the order index for the property in the admin ui /// Determines the order index for the property in the admin ui
/// </summary> /// </summary>
/// <param name="index">The value for the option</param>
/// <seealso cref="TableConfigurator{TModel}.SetOrderIndex"/> /// <seealso cref="TableConfigurator{TModel}.SetOrderIndex"/>
public PropertyConfigurator<TProp> SetOrderIndex(int index) { public PropertyConfigurator<TProp> SetOrderIndex(int index) {
InnerConfig.Order = index; InnerConfig.Order = index;
@@ -242,10 +262,12 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
} }
} }
/// <inheritdoc/>
public sealed class VirtualPropertyConfigurator<TModel>(VirtualPropertyConfig config) : PropertyConfigurator<string>(config) { public sealed class VirtualPropertyConfigurator<TModel>(VirtualPropertyConfig config) : PropertyConfigurator<string>(config) {
/// <summary> /// <summary>
/// Determines the function used for parsing the value provided in the editor dialog to the actual model value /// Determines the function used for parsing the value provided in the editor dialog to the actual model value
/// </summary> /// </summary>
/// <param name="parser">The function that takes in the parent object and the user input and applies all necessary changes</param>
public VirtualPropertyConfigurator<TModel> SetVirtualParser(Action<TModel, string, IServiceProvider> parser) { public VirtualPropertyConfigurator<TModel> SetVirtualParser(Action<TModel, string, IServiceProvider> parser) {
var cfg = InnerConfig as VirtualPropertyConfig; var cfg = InnerConfig as VirtualPropertyConfig;
@@ -257,7 +279,7 @@ public sealed class VirtualPropertyConfigurator<TModel>(VirtualPropertyConfig co
return this; return this;
} }
/// <inheritdoc cref="SetVirtualParser{TModel}(System.Action{TModel,string,System.IServiceProvider})"/> /// <inheritdoc cref="SetVirtualParser(System.Action{TModel, System.String, System.IServiceProvider})" />
public VirtualPropertyConfigurator<TModel> SetVirtualParser(Func<TModel, string, IServiceProvider, Task> parser) { public VirtualPropertyConfigurator<TModel> SetVirtualParser(Func<TModel, string, IServiceProvider, Task> parser) {
var cfg = InnerConfig as VirtualPropertyConfig; var cfg = InnerConfig as VirtualPropertyConfig;

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,19 @@ 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) { /// <param name="ignore">The toggle for the option</param>
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>
/// <param name="show">The toggle for the option</param>
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
@@ -136,6 +146,7 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the name for the table used in the admin ui and url for the table page /// Determines the name for the table used in the admin ui and url for the table page
/// </summary> /// </summary>
/// <param name="name">The value for the option</param>
public TableConfigurator<TModel> SetDisplayName(string name) { public TableConfigurator<TModel> SetDisplayName(string name) {
InnerConfig.DisplayName = name; InnerConfig.DisplayName = name;
return this; return this;
@@ -144,6 +155,7 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the description displayed in the admin ui /// Determines the description displayed in the admin ui
/// </summary> /// </summary>
/// <param name="description">The value for the option</param>
public TableConfigurator<TModel> SetDescription(string description) { public TableConfigurator<TModel> SetDescription(string description) {
InnerConfig.Description = description; InnerConfig.Description = description;
return this; return this;
@@ -152,6 +164,7 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the order index for the table in the admin ui /// Determines the order index for the table in the admin ui
/// </summary> /// </summary>
/// <param name="index">The value for the option</param>
/// <seealso cref="PropertyConfigurator{TProp}.SetOrderIndex"/> /// <seealso cref="PropertyConfigurator{TProp}.SetOrderIndex"/>
public TableConfigurator<TModel> SetOrderIndex(int index) { public TableConfigurator<TModel> SetOrderIndex(int index) {
InnerConfig.Order = index; InnerConfig.Order = index;
@@ -161,6 +174,8 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the policy needed by a user in order to view the table /// Determines the policy needed by a user in order to view the table
/// </summary> /// </summary>
/// <param name="policy">The value for the option</param>
/// <seealso cref="SetCombinedPolicy"/>
public TableConfigurator<TModel> SetViewPolicy(string policy) { public TableConfigurator<TModel> SetViewPolicy(string policy) {
InnerConfig.ViewPolicy = policy; InnerConfig.ViewPolicy = policy;
return this; return this;
@@ -169,6 +184,8 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the policy needed by a user in order to edit the entries /// Determines the policy needed by a user in order to edit the entries
/// </summary> /// </summary>
/// <param name="policy">The value for the option</param>
/// <seealso cref="SetCombinedPolicy"/>
public TableConfigurator<TModel> SetUpdatePolicy(string policy) { public TableConfigurator<TModel> SetUpdatePolicy(string policy) {
InnerConfig.UpdatePolicy = policy; InnerConfig.UpdatePolicy = policy;
return this; return this;
@@ -177,6 +194,8 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the policy needed by a user in order to create entries /// Determines the policy needed by a user in order to create entries
/// </summary> /// </summary>
/// <param name="policy">The value for the option</param>
/// <seealso cref="SetCombinedPolicy"/>
public TableConfigurator<TModel> SetCreatePolicy(string policy) { public TableConfigurator<TModel> SetCreatePolicy(string policy) {
InnerConfig.CreatePolicy = policy; InnerConfig.CreatePolicy = policy;
return this; return this;
@@ -185,11 +204,25 @@ public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// Determines the policy needed by a user in order to delete entries /// Determines the policy needed by a user in order to delete entries
/// </summary> /// </summary>
/// <param name="policy">The value for the option</param>
/// <seealso cref="SetCombinedPolicy"/>
public TableConfigurator<TModel> SetDeletePolicy(string policy) { public TableConfigurator<TModel> SetDeletePolicy(string policy) {
InnerConfig.DeletePolicy = policy; InnerConfig.DeletePolicy = policy;
return this; return this;
} }
/// <summary>
/// Sets the view, update, create and delete policies to the same value
/// </summary>
/// <param name="policy">The value for the options</param>
public TableConfigurator<TModel> SetCombinedPolicy(string policy) {
InnerConfig.ViewPolicy = policy;
InnerConfig.UpdatePolicy = policy;
InnerConfig.CreatePolicy = policy;
InnerConfig.DeletePolicy = policy;
return this;
}
/// <summary> /// <summary>
/// Adds a callback handler of the provided type /// Adds a callback handler of the provided type
/// </summary> /// </summary>

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,78 @@
namespace HopFrame.Core.Repositories;
/// <summary>
/// The middleware used to access data for custom repositories
/// </summary>
/// <typeparam name="TModel">The type of the entity that is managed by this repository</typeparam>
/// <typeparam name="TKey">The type of the primary key of the entity that is managed by this repository</typeparam>
public interface IHopFrameRepository<TModel, in TKey> where TModel : class {
/// <summary>
/// Gets used to load all entities of the current page to display
/// </summary>
/// <param name="page">The page the user is currently on (starts at 0)</param>
/// <param name="perPage">The maximum number of elements that should be returned</param>
/// <returns>The entries for the requested page</returns>
Task<IEnumerable<TModel>> LoadPage(int page, int perPage);
/// <summary>
/// Gets used to filter the entries by the entered search term
/// </summary>
/// <param name="searchTerm">The search text that was entered by the user</param>
/// <param name="page">The page the user is currently on (starts at 0)</param>
/// <param name="perPage">The maximum number of elements that should be returned</param>
/// <returns>A list of the filtered entries on the current page with the total number of pages</returns>
/// <seealso cref="SearchResult{TModel}"/>
Task<SearchResult<TModel>> Search(string searchTerm, int page, int perPage);
/// <summary>
/// Gets used to determine the total number of pages
/// </summary>
/// <param name="perPage">The maximum number of elements per page</param>
/// <returns>The total number of pages</returns>
Task<int> GetTotalPageCount(int perPage);
/// <summary>
/// Gets used when an entry is created through the UI
/// </summary>
/// <param name="item">The entry that needs to be saved</param>
Task CreateItem(TModel item);
/// <summary>
/// Gets used when an entry is modified through the UI
/// </summary>
/// <param name="item">The modified entry that needs to be saved</param>
Task EditItem(TModel item);
/// <summary>
/// Gets used when an entry is deleted through the UI
/// </summary>
/// <param name="item">The entry that should be deleted</param>
Task DeleteItem(TModel item);
/// <summary>
/// Gets used to receive information about a specific entry from the dataset
/// </summary>
/// <param name="key">The primary key of the desired entry</param>
/// <returns>Either the entry if one is found or <c>null</c></returns>
Task<TModel?> GetOne(TKey key);
}
/// <summary>
/// The object that is passed to the UI to display the search results
/// </summary>
/// <param name="items">The found entries on the current page</param>
/// <param name="pageCount">The total number of pages that the search contains</param>
/// <typeparam name="TModel">The type of the entity</typeparam>
public readonly struct SearchResult<TModel>(IEnumerable<TModel> items, int pageCount) {
/// <summary>
/// The found entries on the current page
/// </summary>
public IEnumerable<TModel> Items { get; init; } = items;
/// <summary>
/// The total number of pages that the search contains
/// </summary>
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;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType); var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; 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;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType); var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; 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,31 +1,46 @@
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) {
@@ -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" />
@@ -77,22 +98,22 @@
Sortable="@property.Sortable"/> Sortable="@property.Sortable"/>
} }
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) { @if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy || _pluginButtons.Where(pb => pb.IsForTable(_config)).Any(pb => pb.Position == PluginButtonPosition.OnEntry))) {
<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] [Parameter]
public required string TableDisplayName { get; set; } public string TableDisplayName { get; set; } = null!;
[Parameter]
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);
@@ -275,6 +307,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

@@ -1,4 +1,7 @@
namespace HopFrame.Web.Plugins.Annotations; namespace HopFrame.Web.Plugins.Annotations;
/// <summary>
/// Indicates, that the method is a handler for an event
/// </summary>
[AttributeUsage(AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Method)]
public class EventHandlerAttribute : Attribute; public class EventHandlerAttribute : Attribute;

View File

@@ -2,17 +2,42 @@
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// Raised before an entry should be deleted
/// </summary>
public sealed class DeleteEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public sealed class DeleteEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// The entry that is about to be deleted
/// </summary>
public required object Entity { get; init; } public required object Entity { get; init; }
} }
/// <summary>
/// Raised before a new entry gets saved to the dataset
/// </summary>
public sealed class CreateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender); public sealed class CreateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender);
/// <summary>
/// Raised before any changes are saved to the dataset
/// </summary>
public sealed class UpdateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public sealed class UpdateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// The entry that is about to be updated
/// </summary>
public required object Entity { get; init; } public required object Entity { get; init; }
} }
/// <summary>
/// Raised when an entity is selected or deselected in the relation picker dialog
/// </summary>
public sealed class SelectEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public sealed class SelectEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// The entry that is being selected
/// </summary>
public required object Entity { get; init; } public required object Entity { get; init; }
/// <summary>
/// Indicates whether the entry is selected (true) or deselected (false)
/// </summary>
public required bool Selected { get; set; } public required bool Selected { get; set; }
} }

View File

@@ -4,24 +4,47 @@ using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// The base event with parameters for every event
/// </summary>
public abstract class HopFrameEventArgs(object internalSender) { public abstract class HopFrameEventArgs(object internalSender) {
internal object InternalSender { get; } = internalSender; internal object InternalSender { get; } = internalSender;
/// <summary>
/// Indicates if the event got canceled by any event handler
/// </summary>
public bool IsCanceled { get; protected set; } public bool IsCanceled { get; protected set; }
/// <summary>
/// Determines if the action that is about to be executed should be canceled
/// </summary>
public void SetCancelled(bool canceled) => IsCanceled = canceled; public void SetCancelled(bool canceled) => IsCanceled = canceled;
} }
/// <inheritdoc/>
public abstract class HopFrameEventArgs<TSender>(TSender sender) : HopFrameEventArgs(sender) where TSender : class { public abstract class HopFrameEventArgs<TSender>(TSender sender) : HopFrameEventArgs(sender) where TSender : class {
/// <summary>
/// The element that raised the event
/// </summary>
public TSender Sender => (TSender)InternalSender; public TSender Sender => (TSender)InternalSender;
} }
public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender) /// <summary>
: HopFrameEventArgs<HopFrameTablePage>(sender) { /// The base event with parameters for every event raised by a <see cref="HopFrameTablePage"/>
/// </summary>
public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender) : HopFrameEventArgs<HopFrameTablePage>(sender) {
/// <summary>
/// The configuration of the table page that raised the event
/// </summary>
public required TableConfig Table { get; init; } public required TableConfig Table { get; init; }
} }
public abstract class HopFrameEditorEventArgs(HopFrameEditor sender) /// <summary>
: HopFrameEventArgs<HopFrameEditor>(sender) { /// The base event with parameters for every event raised by a <see cref="HopFrameEditor"/>
/// </summary>
public abstract class HopFrameEditorEventArgs(HopFrameEditor sender) : HopFrameEventArgs<HopFrameEditor>(sender) {
/// <summary>
/// The configuration of the parent table of the editor that raised the event
/// </summary>
public required TableConfig Table { get; init; } public required TableConfig Table { get; init; }
} }

View File

@@ -2,8 +2,22 @@
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// Raised when the user is about to change the page he is currently on
/// </summary>
public sealed class PageChangeEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public sealed class PageChangeEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// The page he is currently on
/// </summary>
public required int CurrentPage { get; init; } public required int CurrentPage { get; init; }
/// <summary>
/// The total number of pages his table can currently display
/// </summary>
public required int TotalPages { get; init; } public required int TotalPages { get; init; }
/// <summary>
/// The new page he tries to access
/// </summary>
public required int NewPage { get; set; } public required int NewPage { get; set; }
} }

View File

@@ -2,6 +2,7 @@
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
public sealed class ReloadEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { /// <summary>
/// Raised before the table is about to reload its data
} /// </summary>
public sealed class ReloadEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender);

View File

@@ -3,9 +3,21 @@ using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// Raised before the search results are loaded
/// </summary>
/// <param name="sender"></param>
public sealed class SearchEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public sealed class SearchEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// The search term the user entered
/// </summary>
public required string SearchTerm { get; set; } public required string SearchTerm { get; set; }
/// <summary>
/// The page the user is currently on
/// </summary>
public required int CurrentPage { get; init; } public required int CurrentPage { get; init; }
internal IEnumerable<object>? SearchResult { get; set; } internal IEnumerable<object>? SearchResult { get; set; }
internal int TotalPages { get; set; } internal int TotalPages { get; set; }

View File

@@ -4,10 +4,28 @@ using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// Raised when a table is initialized
/// </summary>
/// <param name="sender"></param>
public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) { public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
/// <summary>
/// List of all buttons added by the plugins
/// </summary>
public List<PluginButton> PluginButtons { get; } = new(); public List<PluginButton> PluginButtons { get; } = new();
/// <summary>
/// Toggles for the native buttons on the page
/// </summary>
public DefaultButtonToggles DefaultButtons { get; set; } = new(); public DefaultButtonToggles DefaultButtons { get; set; } = new();
/// <summary>
/// Adds a custom button to the top bar of the page
/// </summary>
/// <param name="title">The text displayed on the button</param>
/// <param name="callback">The function that is invoked when the button is pressed</param>
/// <param name="pushRight">Determines if the button should be displayed on the right</param>
/// <param name="icon">Optional: The icon displayed next to the title</param>
public void AddPageButton(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) { public void AddPageButton(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) {
PluginButtons.Add(new() { PluginButtons.Add(new() {
Title = title, Title = title,
@@ -17,6 +35,7 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <inheritdoc cref="AddPageButton(string,System.Func{System.Threading.Tasks.Task},bool,Microsoft.FluentUI.AspNetCore.Components.IconInfo?)"/>
public void AddPageButton(string title, Action callback, bool pushRight = false, IconInfo? icon = null) { public void AddPageButton(string title, Action callback, bool pushRight = false, IconInfo? icon = null) {
AddPageButton(title, () => { AddPageButton(title, () => {
callback.Invoke(); callback.Invoke();
@@ -24,6 +43,11 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}, pushRight, icon); }, pushRight, icon);
} }
/// <summary>
/// Adds a custom button to the Actions column next to every entry
/// </summary>
/// <param name="icon">The icon displayed in the button</param>
/// <param name="callback">The function that is invoked when the button is pressed</param>
public void AddEntityButton(IconInfo icon, Func<object, TableConfig, Task> callback) { public void AddEntityButton(IconInfo icon, Func<object, TableConfig, Task> callback) {
PluginButtons.Add(new() { PluginButtons.Add(new() {
Icon = icon, Icon = icon,
@@ -32,6 +56,7 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <inheritdoc cref="AddEntityButton(IconInfo,System.Func{object,TableConfig,System.Threading.Tasks.Task})"/>
public void AddEntityButton(IconInfo icon, Action<object, TableConfig> callback) { public void AddEntityButton(IconInfo icon, Action<object, TableConfig> callback) {
AddEntityButton(icon, (obj, cfg) => { AddEntityButton(icon, (obj, cfg) => {
callback.Invoke(obj, cfg); callback.Invoke(obj, cfg);
@@ -39,6 +64,8 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <typeparam name="TEntity">The entity type of the table that should display this button</typeparam>
/// <inheritdoc cref="AddEntityButton(IconInfo,System.Func{object,TableConfig,System.Threading.Tasks.Task})"/>
public void AddEntityButton<TEntity>(IconInfo icon, Func<TEntity, TableConfig, Task> callback) { public void AddEntityButton<TEntity>(IconInfo icon, Func<TEntity, TableConfig, Task> callback) {
PluginButtons.Add(new() { PluginButtons.Add(new() {
Icon = icon, Icon = icon,
@@ -48,6 +75,8 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <typeparam name="TEntity">The entity type of the table that should display this button</typeparam>
/// <inheritdoc cref="AddEntityButton(IconInfo,System.Func{object,TableConfig,System.Threading.Tasks.Task})"/>
public void AddEntityButton<TEntity>(IconInfo icon, Action<TEntity, TableConfig> callback) { public void AddEntityButton<TEntity>(IconInfo icon, Action<TEntity, TableConfig> callback) {
AddEntityButton<TEntity>(icon, (obj, cfg) => { AddEntityButton<TEntity>(icon, (obj, cfg) => {
callback.Invoke(obj, cfg); callback.Invoke(obj, cfg);
@@ -55,6 +84,8 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <typeparam name="TEntity">The entity type of the table that should display this button</typeparam>
/// <inheritdoc cref="AddPageButton(string,System.Func{System.Threading.Tasks.Task},bool,Microsoft.FluentUI.AspNetCore.Components.IconInfo?)"/>
public void AddPageButton<TEntity>(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) { public void AddPageButton<TEntity>(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) {
PluginButtons.Add(new() { PluginButtons.Add(new() {
Title = title, Title = title,
@@ -65,6 +96,8 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
}); });
} }
/// <typeparam name="TEntity">The entity type of the table that should display this button</typeparam>
/// <inheritdoc cref="AddPageButton(string,System.Func{System.Threading.Tasks.Task},bool,Microsoft.FluentUI.AspNetCore.Components.IconInfo?)"/>
public void AddPageButton<TEntity>(string title, Action callback, bool pushRight = false, IconInfo? icon = null) { public void AddPageButton<TEntity>(string title, Action callback, bool pushRight = false, IconInfo? icon = null) {
AddPageButton<TEntity>(title, () => { AddPageButton<TEntity>(title, () => {
callback.Invoke(); callback.Invoke();
@@ -73,11 +106,33 @@ public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePage
} }
} }
/// <summary>
/// A wrapper object containing all information about a custom button
/// </summary>
public struct PluginButton { public struct PluginButton {
/// <summary>
/// The position of the button
/// </summary>
public PluginButtonPosition Position { get; set; } public PluginButtonPosition Position { get; set; }
/// <summary>
/// The function that is invoked when the button is pressed
/// </summary>
public Func<object, TableConfig, Task> Handler { get; set; } public Func<object, TableConfig, Task> Handler { get; set; }
/// <summary>
/// The text on the button if supported
/// </summary>
public string? Title { get; set; } public string? Title { get; set; }
/// <summary>
/// The icon displayed on the button
/// </summary>
public IconInfo? Icon { get; set; } public IconInfo? Icon { get; set; }
/// <summary>
/// The entity type of the table that should display this button
/// </summary>
public Type? TableFilter { get; set; } public Type? TableFilter { get; set; }
internal bool IsForTable(TableConfig? config) { internal bool IsForTable(TableConfig? config) {
@@ -87,12 +142,18 @@ public struct PluginButton {
} }
} }
/// <summary>
/// All available positions for a custom button
/// </summary>
public enum PluginButtonPosition { public enum PluginButtonPosition {
TopLeft = 0, TopLeft = 0,
TopRight = 1, TopRight = 1,
OnEntry = 2 OnEntry = 2
} }
/// <summary>
/// Toggles for the native buttons on the page
/// </summary>
public struct DefaultButtonToggles() { public struct DefaultButtonToggles() {
public bool ShowRefreshButton { get; set; } = true; public bool ShowRefreshButton { get; set; } = true;
public bool ShowAddEntityButton { get; set; } = true; public bool ShowAddEntityButton { get; set; } = true;

View File

@@ -3,7 +3,17 @@ using HopFrame.Web.Components.Dialogs;
namespace HopFrame.Web.Plugins.Events; namespace HopFrame.Web.Plugins.Events;
/// <summary>
/// Raised when a property in the editor is validated
/// </summary>
public sealed class ValidationEvent(HopFrameEditor sender) : HopFrameEditorEventArgs(sender) { public sealed class ValidationEvent(HopFrameEditor sender) : HopFrameEditorEventArgs(sender) {
/// <summary>
/// All errors found by the validators. Can be modified to display more (or less) errors
/// </summary>
public required IList<string> Errors { get; init; } public required IList<string> Errors { get; init; }
/// <summary>
/// The property that is validated
/// </summary>
public required PropertyConfig Property { get; init; } public required PropertyConfig Property { get; init; }
} }

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, PropertyConfig config) {
if (config.IsRelation) {
var table = explorer.GetTable(entity.GetType());
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.GetValue(entity)?.ToString();
}
} }
private string? SelectPrimaryKey(object entity) {
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()
@@ -57,7 +59,7 @@ public static class ServiceCollectionExtensions {
/// Adds the HopFrame admin ui endpoints /// Adds the HopFrame admin ui endpoints
/// </summary> /// </summary>
/// <seealso cref="AddHopFramePages"/> /// <seealso cref="AddHopFramePages"/>
[Obsolete($"Use '{nameof(AddHopFramePages)}' instead")] [Obsolete($"Use {nameof(AddHopFramePages)} instead")]
public static RazorComponentsEndpointConventionBuilder MapHopFramePages(this RazorComponentsEndpointConventionBuilder builder) { public static RazorComponentsEndpointConventionBuilder MapHopFramePages(this RazorComponentsEndpointConventionBuilder builder) {
return AddHopFramePages(builder); return AddHopFramePages(builder);
} }
@@ -72,6 +74,9 @@ public static class ServiceCollectionExtensions {
return builder; return builder;
} }
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static WebApplication MapHopFrame(this WebApplication app) { public static WebApplication MapHopFrame(this WebApplication app) {
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();

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,27 @@
using HopFrame.Core.Config;
namespace HopFrame.Web.Services;
/// <summary>
/// Accessor for the advanced search suggestion feature
/// </summary>
public interface ISearchSuggestionProvider {
/// <summary>
/// Generates a list of search suggestions based on the current search term
/// </summary>
/// <param name="table">The current table for context</param>
/// <param name="searchText">The partial input by the user</param>
/// <returns></returns>
public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText);
/// <summary>
/// Generates the new search term by appending the generated text of the selected suggestion
/// </summary>
/// <param name="table">The current table for context</param>
/// <param name="searchText">The partial input by the user</param>
/// <param name="selectedSuggestion">The suggestion that was selected</param>
/// <returns></returns>
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);