39 Commits

Author SHA1 Message Date
052eed17de updated ci docker image 2025-11-30 17:49:37 +01:00
a14f17e8c3 Upgraded to dotnet 10 2025-11-30 17:46:54 +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
7e5d50b1c9 Fixed directory in pipeline 2025-02-28 12:39:07 +01:00
18937f9275 Removed unused dependency 2025-02-28 12:36:44 +01:00
2fdd305cd7 Prepared CI for v3.2.0 2025-02-28 12:35:34 +01:00
6bc2479984 Patched CI 2025-02-28 12:29:26 +01:00
b9c34a85df Merge branch 'feature/exporters' into 'dev'
Resolve "Exporters"

Closes #25

See merge request leon.hoppe/hopframe!33
2025-02-28 11:22:52 +00:00
e773871dc0 Updated documentation 2025-02-28 12:23:11 +01:00
86ace64618 Finished converter plugin 2025-02-28 12:15:32 +01:00
6c42008a28 Added basic export and import feature 2025-02-15 13:49:39 +01:00
0262b3b97b Merge branch 'feature/virtual-properties' into 'dev'
Resolve "Fully virtual properties"

Closes #24

See merge request leon.hoppe/hopframe!32
2025-02-15 11:09:20 +00:00
84c37012ec Added fully virtual properties 2025-02-14 18:31:00 +01:00
56d45575f8 Merge branch 'feature/docs' into 'dev'
Resolve "Documetation website"

Closes #31

See merge request leon.hoppe/hopframe!31
2025-02-13 18:24:30 +00:00
9a66f88f3c Made documentation ready for production 2025-02-13 19:11:06 +01:00
93d41ad6d3 Finished documentation 2025-02-13 18:10:43 +01:00
08d4ddb2c6 Added documentation for the core module 2025-02-12 11:19:34 +01:00
47f30bf33f Started working on documentation 2025-02-11 20:52:52 +01:00
8db0f84a80 Added custom search functionality 2025-02-05 18:12:34 +01:00
46f14d3ddb Merge branch 'feature/plugins' into 'dev'
Resolve "Plugin support"

Closes #29

See merge request leon.hoppe/hopframe!30
2025-02-05 16:56:59 +00:00
43fda30a01 Added default button removal feature 2025-02-05 17:56:08 +01:00
23c5115c99 Added plugin buttons 2025-02-05 17:35:12 +01:00
fb761c74d2 Passed cancellation tokens to event handlers if needed 2025-02-05 16:47:47 +01:00
13e9af892c Added plugin events 2025-02-02 19:06:41 +01:00
4cfeaab652 Merge branch 'feature/custom-views' into 'dev'
Resolve "Custom views"

Closes #30

See merge request leon.hoppe/hopframe!29
2025-02-01 15:16:52 +00:00
2256a59f9a Added custom views 2025-02-01 16:15:28 +01:00
bfea4e9cff Fixed event emitter service scope 2025-02-01 12:01:49 +01:00
7ce066df7b Merge branch 'feature/events' into 'dev'
Resolve "Action Events"

Closes #23

See merge request leon.hoppe/hopframe!28
2025-02-01 10:54:07 +00:00
39641f18a8 Added modular event system 2025-02-01 11:50:52 +01:00
101 changed files with 4011 additions and 481 deletions

View File

@@ -1,16 +1,15 @@
image: mcr.microsoft.com/dotnet/sdk:9.0
image: mcr.microsoft.com/dotnet/sdk:10.0
stages:
- build
- test
- publish
before_script:
- echo "Setting up environment"
- 'dotnet --version'
- publish-help
build:
stage: build
only:
- pushes
script:
- dotnet restore
- dotnet build --configuration Release --no-restore
@@ -21,10 +20,31 @@ build:
test:
stage: test
only:
- pushes
script:
- dotnet test --verbosity normal
dependencies:
- build
- 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
@@ -37,3 +57,21 @@ publish:
dependencies:
- build
- test
publish-help:
stage: publish-help
image: docker:latest
services:
- name: docker:dind
alias: docker
script:
- export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//')
- cd docs
- docker login -u leon.hoppe -p ${CI_REGISTRY_PASSWORD} registry.leon-hoppe.de
- docker build -t registry.leon-hoppe.de/leon.hoppe/hopframe:$VERSION -t registry.leon-hoppe.de/leon.hoppe/hopframe:latest .
- docker push registry.leon-hoppe.de/leon.hoppe/hopframe:$VERSION
- docker push registry.leon-hoppe.de/leon.hoppe/hopframe:latest
only:
- tags
dependencies:
- publish

View File

@@ -2,6 +2,7 @@
<project version="4">
<component name="RiderProjectSettingsUpdater">
<option name="singleClickDiffPreview" value="1" />
<option name="unhandledExceptionsIgnoreList" value="1" />
<option name="vcsConfiguration" value="3" />
</component>
</project>

View File

@@ -11,10 +11,7 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
</list>
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -51,38 +48,14 @@
<component name="GitLabMergeRequestsSettings">{
&quot;selectedUrlAndAccountId&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 name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/02/6ae7626a/IList.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/5b/a350be00/IEnumerable.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/8b/db8582a3/IList`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/ad/ba9a50e7/ICollection.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/fc/6f7933d2/ICollection`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669/List.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/26c9a2fb5243863babc926e4be763daf4128d4f97c4a769cdce1e2e3e5c532/FluentButton.razor.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/2751d5afefca5424bfc4b21347f581372f7a739c0ae4df661ea557fcb97ef20/EnumExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed/ToCollection.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/5a69b82eed595b731b82667db08722b69b82482e275cf32dfb219190e3dc49/CollectionEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/60e7b22380df80ef6fefe43138047f49ec6eff4b25c12b42ce3d6ed5aac/MethodInvokerCommon.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/642391624bd5c30b3411a11434588aba4906207335166b784bf3a4325f6c7/NavigationEntry.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/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/adcd2c45092dd8e4fc412325c8adb75d6e7d8b3e90a9523f167583fb9c60/ServiceCollectionExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/b3ccb66df3646cb51df73ad51716136ebd2eefb4edb1308dd52a7e999582d59e/IBindableColumn.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bfff78ecaa39c818519fc918bb2d4bbdca6ad93d7170f5cf325f67ccd0b97d43/BooleanAsserts.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d04a416cac8afac0341a8be0e859b230f2eae64924298eef48c317ba35916/RenderTreeBuilder.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d39923abb31e6a6e7a9e8173e217da584c54925ce63e568126a2b89b9ab/DefaultRazorComponentsServiceOptionsConfiguration.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d858ddb35a8e36df5573b7612542f9ad50f426b8ab43818587d1ac65fab14829/DatabaseGeneratedAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/eab2d6b892f743a27cb49a139ba782855897baf1233febd2dfd2092f3/EntityEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ece8533187fe96ce67b3ef1c9cc3502ef8da5510aadb132a9b21c5605d7c2119/PropertyColumn.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ee4d234452e240d83e3de396c2e85cbf9ac9fb9add618b955eea196c81aaf8/IDialogContentComponent.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/fc2027f7e776fc105cddb56b1a25eeb3895b3ae6f3aac854d786e63bd01f75e2/CallSiteFactory.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ff37d54b3bf4d2756237fb789635831532603376e940f63d634b869d26d74c/Regular16.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/tests/HopFrame.Tests.Web/Components/Dialogs/HopFrameEditorTests.cs" root0="SKIP_HIGHLIGHTING" />
</component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
@@ -93,7 +66,7 @@
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
<component name="ProjectLevelVcsManager">
<OptionsSetting value="false" id="Update" />
<ConfirmationsSetting value="2" id="Add" />
</component>
@@ -101,55 +74,59 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Launch Settings Profile.HopFrame.Testing.Api: https.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
".NET Project.HopFrame.Testing.executor": "Run",
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
"git-widget-placeholder": "!27 on fix/selection",
"list.type.of.created.stylesheet": "CSS",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.environmentSetup",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.HopFrame.Testing.Api: https.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;,
&quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
&quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&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;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;,
&quot;git-widget-placeholder&quot;: &quot;dev&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="HopFrame.Testing: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<configuration name="HopFrame.Testing: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile" activateToolWindowBeforeRun="false">
<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_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2">
<option name="Build" />
</method>
@@ -158,13 +135,14 @@
<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_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2">
<option name="Build" />
</method>
@@ -173,13 +151,14 @@
<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_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2">
<option name="Build" />
</method>
@@ -220,135 +199,39 @@
<workItem from="1737993570961" duration="4163000" />
<workItem from="1738054766160" duration="7449000" />
<workItem from="1738075629332" duration="8862000" />
<workItem from="1738335286481" duration="1624000" />
</task>
<task id="LOCAL-00001" summary="Added basic configuration">
<option name="closed" value="true" />
<created>1736850899254</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1736850899254</updated>
</task>
<task id="LOCAL-00002" summary="Added admin page navigation">
<option name="closed" value="true" />
<created>1736855209077</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1736855209077</updated>
</task>
<task id="LOCAL-00003" summary="Added database loading logic">
<option name="closed" value="true" />
<created>1736859917232</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1736859917232</updated>
</task>
<task id="LOCAL-00004" summary="Started working on listing page">
<option name="closed" value="true" />
<created>1736885531216</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1736885531216</updated>
</task>
<task id="LOCAL-00005" summary="Added entry saving support">
<option name="closed" value="true" />
<created>1736970238802</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1736970238802</updated>
</task>
<task id="LOCAL-00006" summary="Added reload button and animation">
<option name="closed" value="true" />
<created>1737023058093</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1737023058093</updated>
</task>
<task id="LOCAL-00007" summary="Added relation picker dialog">
<option name="closed" value="true" />
<created>1737035288104</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1737035288104</updated>
</task>
<task id="LOCAL-00008" summary="Added automatic relation mapping">
<option name="closed" value="true" />
<created>1737037853482</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1737037853482</updated>
</task>
<task id="LOCAL-00009" summary="Added property validation">
<option name="closed" value="true" />
<created>1737040612038</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1737040612038</updated>
</task>
<task id="LOCAL-00010" summary="Added creation/modification confirmation">
<option name="closed" value="true" />
<created>1737040946489</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1737040946489</updated>
</task>
<task id="LOCAL-00011" summary="Removed Template">
<option name="closed" value="true" />
<created>1737042229086</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1737042229086</updated>
</task>
<task id="LOCAL-00012" summary="Added policy validation, ordering and virtual listing properties">
<option name="closed" value="true" />
<created>1737055409534</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1737055409535</updated>
</task>
<task id="LOCAL-00013" summary="Added n -&gt; m relation support">
<option name="closed" value="true" />
<created>1737129518866</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1737129518866</updated>
</task>
<task id="LOCAL-00014" summary="Added text area support and DI support for modifier functions">
<option name="closed" value="true" />
<created>1737202192471</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1737202192471</updated>
</task>
<task id="LOCAL-00015" summary="Addressed all build warnings">
<option name="closed" value="true" />
<created>1737203441319</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1737203441319</updated>
</task>
<task id="LOCAL-00016" summary="Added documentation for the configurators and service extensions methods">
<option name="closed" value="true" />
<created>1737208088933</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1737208088933</updated>
<workItem from="1738335286481" duration="2039000" />
<workItem from="1738403493974" duration="4231000" />
<workItem from="1738418482606" duration="2795000" />
<workItem from="1738421294144" duration="1651000" />
<workItem from="1738422949337" duration="141000" />
<workItem from="1738512801911" duration="6776000" />
<workItem from="1738769458367" duration="5256000" />
<workItem from="1738774834563" duration="728000" />
<workItem from="1739301922710" duration="33000" />
<workItem from="1739352479748" duration="3047000" />
<workItem from="1739369355001" duration="1751000" />
<workItem from="1739461452173" duration="5533000" />
<workItem from="1739550750776" duration="3613000" />
<workItem from="1739617785048" duration="5992000" />
<workItem from="1739975843065" duration="1921000" />
<workItem from="1740168829540" duration="1382000" />
<workItem from="1740595969750" duration="34000" />
<workItem from="1740736919561" duration="191000" />
<workItem from="1740738257628" duration="3216000" />
<workItem from="1740741585276" duration="17000" />
<workItem from="1740742098571" duration="78000" />
<workItem from="1740742471317" duration="672000" />
<workItem from="1741974241977" duration="10854000" />
<workItem from="1742038098473" duration="990000" />
<workItem from="1742059898156" duration="3488000" />
<workItem from="1744725284649" duration="60000" />
<workItem from="1744916016381" duration="66000" />
<workItem from="1744916106166" duration="49000" />
<workItem from="1744966207145" duration="5231000" />
<workItem from="1751713720880" duration="8243000" />
<workItem from="1751741813788" duration="4623000" />
<workItem from="1764520233816" duration="726000" />
<workItem from="1764520962745" duration="260000" />
</task>
<task id="LOCAL-00017" summary="Created tests for the core module">
<option name="closed" value="true" />
@@ -470,7 +353,279 @@
<option name="project" value="LOCAL" />
<updated>1738084259089</updated>
</task>
<option name="localTasksCounter" value="32" />
<task id="LOCAL-00032" summary="Removed select all button">
<option name="closed" value="true" />
<created>1738337068205</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1738337068205</updated>
</task>
<task id="LOCAL-00033" summary="Added missing installation instructions">
<option name="closed" value="true" />
<created>1738337314351</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1738337314351</updated>
</task>
<task id="LOCAL-00034" summary="Added modular event system">
<option name="closed" value="true" />
<created>1738407061976</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1738407061976</updated>
</task>
<task id="LOCAL-00035" summary="Fixed event emitter service scope">
<option name="closed" value="true" />
<created>1738407710507</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1738407710507</updated>
</task>
<task id="LOCAL-00036" summary="Added custom views">
<option name="closed" value="true" />
<created>1738422931038</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1738422931038</updated>
</task>
<task id="LOCAL-00037" summary="Added plugin events">
<option name="closed" value="true" />
<created>1738519603597</created>
<option name="number" value="00037" />
<option name="presentableId" value="LOCAL-00037" />
<option name="project" value="LOCAL" />
<updated>1738519603597</updated>
</task>
<task id="LOCAL-00038" summary="Passed cancellation tokens to event handlers if needed">
<option name="closed" value="true" />
<created>1738770468949</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1738770468949</updated>
</task>
<task id="LOCAL-00039" summary="Added plugin buttons">
<option name="closed" value="true" />
<created>1738773315593</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1738773315593</updated>
</task>
<task id="LOCAL-00040" summary="Added default button removal feature">
<option name="closed" value="true" />
<created>1738774569657</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1738774569657</updated>
</task>
<task id="LOCAL-00041" summary="Added custom search functionality">
<option name="closed" value="true" />
<created>1738775556256</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1738775556256</updated>
</task>
<task id="LOCAL-00042" summary="Added fully virtual properties">
<option name="closed" value="true" />
<created>1739554261551</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1739554261551</updated>
</task>
<task id="LOCAL-00043" summary="Added basic export and import feature">
<option name="closed" value="true" />
<created>1739623781007</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1739623781007</updated>
</task>
<task id="LOCAL-00044" summary="Finished converter plugin">
<option name="closed" value="true" />
<created>1740741334420</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1740741334420</updated>
</task>
<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="Upgraded to dotnet 10">
<option name="closed" value="true" />
<created>1764521217886</created>
<option name="number" value="00065" />
<option name="presentableId" value="LOCAL-00065" />
<option name="project" value="LOCAL" />
<updated>1764521217886</updated>
</task>
<option name="localTasksCounter" value="66" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -480,38 +635,17 @@
<option name="coveragePercentColumnWidth" value="129" />
<option name="sortOrder" value="DESCENDING" />
<option name="sortedColumn" value="1" />
<option name="symbolColumnWidth" value="451" />
<option name="symbolColumnWidth" value="559" />
<coverage-tree-state>
<expand>
<path>
<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>
<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" />
</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" />
<item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 40% 862/1439" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
</expand>
<select />
@@ -519,33 +653,44 @@
</component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<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">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Added relation picker dialog" />
<MESSAGE value="Added automatic relation mapping" />
<MESSAGE value="Added property validation" />
<MESSAGE value="Added creation/modification confirmation" />
<MESSAGE value="Removed Template" />
<MESSAGE value="Added policy validation, ordering and virtual listing properties" />
<MESSAGE value="Added n -&gt; m relation support" />
<MESSAGE value="Added text area support and DI support for modifier functions" />
<MESSAGE value="Addressed all build warnings" />
<MESSAGE value="Added documentation for the configurators and service extensions methods" />
<MESSAGE value="Created tests for the core module" />
<MESSAGE value="Added more tests" />
<MESSAGE value="Added web module tests" />
<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" />
<option name="LAST_COMMIT_MESSAGE" value="Implemented deferred entry manipulation" />
<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="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="Upgraded to dotnet 10" />
<option name="LAST_COMMIT_MESSAGE" value="Upgraded to dotnet 10" />
</component>
</project>

View File

@@ -4,7 +4,7 @@
Welcome to the **HopFrame**! This project aims to provide a comprehensive and modular framework for easy management of your database.
The framework is designed to be highly configurable, ensuring that developers either quickly add the framework for simple data editing or
configure it to their needs to implement it fully in their data management pipeline.
configure it to their needs to implement it fully in their data management pipeline. Read more in the project [docs](https://hopframe.leon-hoppe.de).
## Features

3
docs/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

8
docs/.idea/docs.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
docs/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/docs.iml" filepath="$PROJECT_DIR$/.idea/docs.iml" />
</modules>
</component>
</project>

6
docs/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

19
docs/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM jetbrains/writerside-builder:243.22562 AS build
ARG INSTANCE=Writerside/hopframe
RUN mkdir /opt/sources
WORKDIR /opt/sources
ADD Writerside ./Writerside
RUN export DISPLAY=:99 && Xvfb :99 & /opt/builder/bin/idea.sh helpbuilderinspect --source-dir /opt/sources --product $INSTANCE --runner other --output-dir /opt/wrs-output/
WORKDIR /opt/wrs-output
RUN unzip -O UTF-8 webHelpHOPFRAME2-all.zip -d /opt/wrs-output/unzipped-artifact
FROM httpd:2.4 AS http-server
COPY --from=build /opt/wrs-output/unzipped-artifact/ /usr/local/apache2/htdocs/

6
docs/Writerside/c.list Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE categories
SYSTEM "https://resources.jetbrains.com/writerside/1.0/categories.dtd">
<categories>
<category id="wrs" name="Writerside documentation" order="1"/>
</categories>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE buildprofiles SYSTEM "https://resources.jetbrains.com/writerside/1.0/build-profiles.dtd">
<buildprofiles xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/build-profiles.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<build-profile instance="hopframe">
<sitemap priority="0.35" change-frequency="monthly"/>
<variables>
<noindex-content>false</noindex-content>
</variables>
</build-profile>
</buildprofiles>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE terms SYSTEM "https://resources.jetbrains.com/writerside/1.0/glossary.dtd">
<terms>
<term name="foo">
Description of what "foo" is.
</term>
</terms>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE instance-profile
SYSTEM "https://resources.jetbrains.com/writerside/1.0/product-profile.dtd">
<instance-profile id="hopframe"
name="HopFrame"
start-page="Overview.md">
<toc-element topic="Overview.md"/>
<toc-element topic="Installation.md"/>
<toc-element topic="Authentication.md"/>
<toc-element toc-title="Core Module">
<toc-element toc-title="Configurations">
<toc-element topic="HopFrameConfig.md"/>
<toc-element topic="DbContextConfig.md"/>
<toc-element topic="TableConfig.md"/>
<toc-element topic="PropertyConfig.md"/>
</toc-element>
<toc-element topic="Callbacks.md"/>
<toc-element topic="Custom-Repositories.md"/>
</toc-element>
<toc-element toc-title="Web Module">
<toc-element toc-title="Interface">
<toc-element topic="Custom-Views.md"/>
<toc-element topic="Table.md"/>
<toc-element topic="Dashboard.md"/>
</toc-element>
<toc-element topic="Plugins.md">
<toc-element topic="Events.md">
</toc-element>
<toc-element topic="Exporter-Plugin.md"/>
</toc-element>
</toc-element>
<toc-element toc-title="Services">
<toc-element topic="IFileService.md"/>
</toc-element>
</instance-profile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE rules SYSTEM "https://resources.jetbrains.com/writerside/1.0/redirection-rules.dtd">
<rules>
<!-- format is as follows
<rule id="<unique id>">
<accepts>page.html</accepts>
</rule>
-->
</rules>

View File

@@ -0,0 +1,35 @@
# Authentication
The HopFrame is a powerful tool to manage your backend data. So you probably don't want anybody to access the pages.
Luckily the HopFrame supports policy based authentication. By default, everybody is allowed to access the whole
HopFrame, but you can restrict that by registering a scoped service implementing the `IHopFrameAuthHandler`.
If no service is registered, the default handler gets registered, but it lets any traffic pass.
## Example
Create a service that handles authentication:
```C#
public class AuthService(IAuthStore store) : IHopFrameAuthHandler {
public async Task<bool> IsAuthenticatedAsync(string? policy) {
var currentUser = await store.GetCurrentUser();
return await store.IsPermitted(currentUser, policy);
}
public async Task<string> GetCurrentUserDisplayNameAsync() {
var currentUser = await store.GetCurrentUser();
return currentUser.FullName;
}
}
```
Now register it in the DI container:
```C#
builder.Services.AddScoped<IHopFrameAuthHandler, AuthService>();
```
**Hint:** You can display the current users name in the ui by enabling the feature in
the [HopFrameConfig](HopFrameConfig.md#displayuserinfo).

View File

@@ -0,0 +1,31 @@
# Callbacks
Callbacks are a way of executing actions on curtain events in the web ui.
## Registering a callback handler
You can register a callback handler using the method provided in the [](TableConfig.md):
```c#
table.AddCallbackHandler(CallbackType.DeleteEntry, (user, services) => {
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("User {user} deleted!", user.Username);
});
```
The callback handler takes the entity that's modified and a `IServiceProvider` as arguments
and can either be synchronous or asynchronous.
## Callback types
```C#
public enum CallbackType {
CreateEntry = 0,
UpdateEntry = 1,
DeleteEntry = 2
}
```
- `CallbackType.CreateEntry`: The handler gets executed, when an entity is created.
- `CallbackType.UpdateEntry`: The handler gets executed, when an entity is updated.
- `CallbackType.DeleteEntry`: The handler gets executed, when an entity is deleted.

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,101 @@
# Custom Views
You can also add your own pages to the HopFrame UI by defining the routes as custom views.
You can do that by using the following extension methods for the [](HopFrameConfig.md):
## Configuration methods
### AddCustomView (With configurator delegate)
Creates an entry to the side menu and dashboard with a custom URL.
```c#
HopFrameConfigurator AddCustomView(string name, string url, Action<CustomViewConfigurator> configuratorDelegate)
```
- **Parameters:**
- `configurator`: The configurator for the HopFrame config that is being created.
- `name`: The name of the navigation entry.
- `url`: The target URL of the navigation entry.
- `configuratorDelegate`: The delegate for configuring the view.
- **Returns:** `HopFrameConfigurator`
### AddCustomView (Without configurator delegate)
Creates an entry to the side menu and dashboard with a custom URL.
```c#
CustomViewConfigurator AddCustomView(string name, string url)
```
- **Parameters:**
- `configurator`: The configurator for the HopFrame config that is being created.
- `name`: The name of the navigation entry.
- `url`: The target URL of the navigation entry.
- **Returns:** `CustomViewConfigurator`
## CustomViewConfigurator
### SetDescription
Sets the description displayed in the dashboard.
```c#
CustomViewConfigurator SetDescription(string description)
```
- **Parameters:**
- `description`: The desired description.
- **Returns:** `CustomViewConfigurator`
### SetPolicy
Sets the policy needed in order to access the view.
```c#
CustomViewConfigurator SetPolicy(string policy)
```
- **Parameters:**
- `policy`: The desired policy.
- **Returns:** `CustomViewConfigurator`
### SetIcon
Sets the icon displayed in the sidebar.
```c#
CustomViewConfigurator SetIcon(string icon)
```
- **Parameters:**
- `icon`: The desired [fluent-icon](https://www.fluentui-blazor.net/Icon#explorer).
- **Returns:** `CustomViewConfigurator`
### SetLinkMatch
Sets the rule for the sidebar to determine if the link is active.
```c#
CustomViewConfigurator SetLinkMatch(NavLinkMatch match)
```
- **Parameters:**
- `match`: The desired match rule.
- **Returns:** `CustomViewConfigurator`
## Example
```C#
builder.Services.AddHopFrame(options => {
options.AddCustomView("Counter", "/counter")
.SetDescription("A custom view")
.SetPolicy("counter.view");
});
```

View File

@@ -0,0 +1,9 @@
# Dashboard
The dashboard gives you an overview of all pages accessible through the HopFrame interface.
An example configuration could lead to something like this:
![dashboard.png](dashboard.png)
You could use the sidebar or the `Open` button to open any page that you have access to.

View File

@@ -0,0 +1,38 @@
# DbContextConfig
This config contains all configurations for the given DbContext type.
## Configuration methods
### Table (With configurator)
Configures the table of the `DbContext` using the provided configurator.
```c#
DbContextConfigurator<TDbContext> Table<TModel>(Action<TableConfigurator<TModel>> configurator) where TModel : class
```
- **Type Parameters:**
- `TModel`: The model of the table for identifying the correct one.
- **Parameters:**
- `configurator`: Used for configuring the table.
- **Returns:** `DbContextConfigurator<TDbContext>`
- **See Also:** [](TableConfig.md)
### Table (Without configurator)
Configures the table of the `DbContext`.
```c#
TableConfigurator<TModel> Table<TModel>() where TModel : class
```
- **Type Parameters:**
- `TModel`: The model of the table for identifying the correct one.
- **Returns:** `TableConfigurator<TModel>`
- **See Also:** [](TableConfig.md)

View File

@@ -0,0 +1,214 @@
# Events
## Base event
Every event inherits from the base event, so these properties and methods are always available
```C#
public abstract class HopFrameEventArgs {
public TSender Sender { get; }
public bool IsCanceled { get; }
public TableConfig Table { get; }
public void SetCancelled(bool canceled);
}
```
**Properties:**
- **Sender**: The sender of the event.
- **Type:** `TSender`
- **IsCanceled**: Indicates whether the event is canceled.
- **Type:** `bool`
- **Table**: The table configuration related to the event.
- **Type:** `TableConfig`
**Methods:**
- **SetCancelled**
- **Parameters:**
- `canceled`: A boolean value to set the cancellation state.
- **Returns:** `void`
## DeleteEntryEvent
Event arguments for a delete entry event.
```C#
public sealed class DeleteEntryEvent : HopFrameEventArgs {
public object Entity { get; }
}
```
**Properties:**
- **Entity**: The entity being deleted.
- **Type:** `object`
## CreateEntryEvent
Event arguments for a create entry event.
```C#
public sealed class CreateEntryEvent : HopFrameEventArgs {
}
```
## UpdateEntryEvent
Event arguments for an update entry event.
```C#
public sealed class UpdateEntryEvent : HopFrameEventArgs {
public object Entity { get; }
}
```
**Properties:**
- **Entity**: The entity being updated.
- **Type:** `object`
## SelectEntryEvent
Event arguments for a select entry event.
```C#
public sealed class SelectEntryEvent : HopFrameEventArgs {
public object Entity { get; }
public bool Selected { get; set; }
}
```
**Properties:**
- **Entity**: The entity being selected.
- **Type:** `object`
- **Selected**: Indicates whether the entity is selected.
- **Type:** `bool`
## PageChangeEvent
Event arguments for a page change event.
```C#
public sealed class PageChangeEvent : HopFrameEventArgs {
public int CurrentPage { get; }
public int TotalPages { get; }
public int NewPage { get; set; }
}
```
**Properties:**
- **CurrentPage**: The current page number.
- **Type:** `int`
- **TotalPages**: The total number of pages.
- **Type:** `int`
- **NewPage**: The new page number to navigate to.
- **Type:** `int`
## ReloadEvent
Event arguments for a reload event.
```C#
public sealed class ReloadEvent : HopFrameEventArgs {
}
```
## SearchEvent
Event arguments for a search event.
```C#
public sealed class SearchEvent : HopFrameEventArgs {
public string SearchTerm { get; set; }
public int CurrentPage { get; }
public void SetSearchResult(IEnumerable result, int totalPages);
}
```
**Properties:**
- **SearchTerm**: The search term used.
- **Type:** `string`
- **CurrentPage**: The current page number.
- **Type:** `int`
- **SearchResult**: The search results.
- **Type:** `IEnumerable<object>?`
- **TotalPages**: The total number of pages of search results.
- **Type:** `int`
**Methods:**
- **SetSearchResult**
- **Parameters:**
- `result`: The current page of search results.
- `totalPages`: The total pages of search results.
- **Returns:** `void`
## TableInitializedEvent
Event arguments for a table initialization event.
```C#
public class TableInitializedEvent : HopFrameEventArgs {
public List<PluginButton> PluginButtons { get; };
public DefaultButtonToggles DefaultButtons { get; set; };
public void AddPageButton(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null);
public void AddPageButton(string title, Action callback, bool pushRight = false, IconInfo? icon = null);
public void AddPageButton<TEntity>(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null);
public void AddPageButton<TEntity>(string title, Action callback, bool pushRight = false, IconInfo? icon = null);
public void AddEntityButton(IconInfo icon, Func<object, TableConfig, Task> callback);
public void AddEntityButton(IconInfo icon, Action<object, TableConfig> callback);
public void AddEntityButton<TEntity>(IconInfo icon, Func<TEntity, TableConfig, Task> callback);
public void AddEntityButton<TEntity>(IconInfo icon, Action<TEntity, TableConfig> callback);
}
```
**Properties:**
- **PluginButtons**: The list of plugin buttons for the table.
- **Type:** `List<PluginButton>`
- **DefaultButtons**: The default button toggles for the table.
- **Type:** `DefaultButtonToggles`
**Methods:**
- **AddPageButton**
- **Parameters:**
- `title`: The title of the button.
- `callback`: The callback function for the button.
- `pushRight`: Indicates whether to push the button to the right. (default: `false`)
- `icon`: The icon for the button. (default: `null`)
- **Returns:** `void`
- **AddEntityButton**
- **Parameters:**
- `icon`: The icon for the button.
- `callback`: The callback function for the button.
- **Returns:** `void`
## ValidationEvent
Event arguments for a validation event.
```C#
public sealed class ValidationEvent : HopFrameEventArgs {
public IList<string> Errors { get; }
public PropertyConfig Property { get; }
}
```
**Properties:**
- **Errors**: The list of validation errors.
- **Type:** `IList<string>`
- **Property**: The property being validated.
- **Type:** `PropertyConfig`

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

@@ -0,0 +1,203 @@
# HopFrameConfig
The HopFrame config is the global object containing all configurations made for the HopFrame.
It is registered as a singleton and can be injected by any service.
But it should be treated as a read only dependency because changing the configuration during runtime is not tested and may cause bugs.
## Changing the configuration
As already mentioned in the [](Installation.md), you configure the HopFrame using the extension method of the `IServiceCollection` named `AddHopFrame`.
This extension method eiter takes a `HopFrameConfig` or a configurator action with a `HopFrameConfigurator` as an argument.
You can optionally also provide a `LibraryConfiguration` for the Fluent UI library and a toggle named `addRazorComponents` which disables the calls for adding
Razor pages with interactive server components if you want to do this yourself.
### Mapping the HopFrame pages
In order for the HopFrame pages to be served you need to add them to your application.
You can do that in two ways:
1. Just use the HopFrame as the razor host (Useful for APIs)
Just map the HopFrame before you run your application:
```c#
app.MapHopFrame();
```
2. Add the HopFrame to your Razor container (Useful for Blazor web apps)
Add the HopFrame to the `MapRazorComponents` call:
```C#
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddHopFramePages();
```
### Example
```C#
builder.Services.AddHopFrame(options => {
options.DisplayUserInfo(false);
options.AddDbContext<DatabaseContext>(context => {
context.Table<User>(table => {
table.Property(u => u.Password)
.DisplayValue(false);
table.Property(u => u.Id)
.IsSortable(false)
.SetOrderIndex(3);
table.SetViewPolicy("policy");
table.Property(u => u.Posts)
.FormatEach<Post>((post, _) => post.Caption);
});
});
options.AddCustomView("Counter", "/counter")
.SetDescription("A custom view")
.SetPolicy("counter.view");
});
```
## Configuration methods
### AddDbContext (With configurator)
Adds all tables defined in the `DbContext` to the HopFrame UI and configures it using the provided configurator.
```c#
HopFrameConfigurator AddDbContext<TDbContext>(Action<DbContextConfigurator<TDbContext>> configurator) where TDbContext : DbContext
```
- **Type Parameters:**
- `TDbContext`: The `DbContext` from which all tables should be added.
- **Parameters:**
- `configurator`: Used for configuring the `DbContext`.
- **Returns:** `HopFrameConfigurator`
- **See Also:** [](DbContextConfig.md)
### AddDbContext (without configurator)
Adds all tables defined in the `DbContext` to the HopFrame UI and configures it.
```c#
DbContextConfigurator<TDbContext> AddDbContext<TDbContext>() where TDbContext : DbContext
```
- **Type Parameters:**
- `TDbContext`: The `DbContext` from which all tables should be added.
- **Returns:** `DbContextConfigurator<TDbContext>`
- **See Also:** [](DbContextConfig.md)
### HasDbContext
Checks if a context is already registered in the HopFrame.
```c#
bool HasDbContext<TDbContext>() where TDbContext : DbContext
```
- **Type Parameters:**
- `TDbContext`: The context that should be checked.
- **Returns:** `true` if the context is already registered, `false` if not.
### GetDbContext
Returns a configurator for the context if it was already defined.
```c#
DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext : DbContext
```
- **Type Parameters:**
- `TDbContext`
- **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
Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin UI.
```c#
HopFrameConfigurator DisplayUserInfo(bool display)
```
- **Parameters:**
- `display`: A boolean value to set if the user info should be displayed.
- **Returns:** `HopFrameConfigurator`
### SetBasePolicy
Sets a default policy that every user needs to have in order to access the admin UI.
```c#
HopFrameConfigurator SetBasePolicy(string basePolicy)
```
- **Parameters:**
- `basePolicy`: The default policy string.
- **Returns:** `HopFrameConfigurator`
### SetLoginPage
Sets a custom login page to redirect to if the request to the admin UI was unauthorized.
```c#
HopFrameConfigurator SetLoginPage(string url)
```
- **Parameters:**
- `url`: The URL of the custom login page.
- **Returns:** `HopFrameConfigurator`

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

@@ -0,0 +1,31 @@
# Installation
Install the nuget package using the CLI or the UI of your IDE:
```bash
dotnet add package HopFrame.Web
```
## Minimal configuration
Configuring HopFrame is straightforward and flexible. You can easily define your contexts, tables, and their properties using the provided configurators.
Simply use your editors intelli-sense to find out what you can configure.
```c#
builder.Services.AddHopFrame(options => {
options.AddDbContext<DatabaseContext>();
});
```
Then you need to map the frontend pages in your application:
```c#
app.MapHopFrame();
```
## Usage
- Navigate to `/admin` to access the admin dashboard and start managing your tables.
- Use the side menu to switch between different tables.
- Utilize the built-in CRUD functionality to manage your data seamlessly.

View File

@@ -0,0 +1,14 @@
# Overview
Welcome to the HopFrame! This project aims to provide a comprehensive and modular framework for easy management of your database.
The framework is designed to be highly configurable, ensuring that developers either quickly add the framework for simple data editing or
configure it to their needs to implement it fully in their data management pipeline.
## Features
- **Dynamic Table Management**: Create, edit, and delete records dynamically with support for various data types including numeric, text, boolean, dates, and relational data.
- **Role-Based Access Control (RBAC)**: Implement fine-grained access control policies for viewing, creating, updating, and deleting records.
- **Modern Design**: A modern and user-friendly interface built with Fluent UI components, ensuring easy to use and pleasing administration pages.
- **Validation and Error Handling**: Comprehensive input validation and error handling to ensure data integrity and provide feedback to users.
- **Support for Complex Data Relationships**: Manage complex relationships between data entities with ease.

View File

@@ -0,0 +1,78 @@
# Plugins
If the default functionality of the HopFrame does not fit your needs, you can easily extend the pages
by using Plugins. They are registered as scoped services so you can use DI like everywhere else.
## Add a plugin
Create a class that represents the plugin:
```C#
public class SearchExtension {
}
```
Then add the plugin to the HopFrame by using the extension method on the [](HopFrameConfig.md):
```C#
builder.Services.AddHopFrame(options => {
options.AddPlugin<SearchExtension>();
});
```
## Configuring the plugin
If you want to change the HopFrame configuration from within your plugin, you can create a static method
and decorate it with the `PluginConfigurator` attribute. Here you can inject the `HopFrameConfigurator`
as an argument and change the configuration. Keep in mind, that this function automatically gets called
when you register your plugin, so any changes after that override the changes made in the plugin.
### Example
```C#
[PluginConfigurator]
public static void Configure(HopFrameConfigurator configurator) {
configurator.AddCustomView("Counter", "/counter")
.SetDescription("A custom view")
.SetPolicy("counter.view");
}
```
## Events
The HopFrame provides various [events](Events.md) that can change how the corresponding action behaves. You can register
event handlers similar to the [configurator method](#configuring-the-plugin). Create a method, that is **not** static
and decorate it with the `EventHandler` attribute. This method can return either `void` or a `Task`. Then declare the
Event type as an argument and the function gets automatically registered as an event handler for the corresponding event.
### Examples
```C#
[EventHandler]
public async Task OnSearch(SearchEvent e) {
var result = await searchHandler.Search(e.Table, e.SearchTerm);
e.SetSearchResult(result.Items, result.TotalPages);
}
[EventHandler]
public void OnDelete(DeleteEntryEvent e) {
cacheHandler.ClearCache(e.Entity);
}
```
## Useful services
### IFileService
If you want to deal with file uploading / downloading, you can use the `IFileService`:
```C#
public interface IFileService {
public Task DownloadFile(string name, byte[] data);
public Task<IBrowserFile> UploadFile();
}
```

View File

@@ -0,0 +1,286 @@
# PropertyConfig
This configuration contains all configurations for the given property type on the table.
## Configuration methods
### SetDisplayName
Sets the title displayed in the table header and edit dialog.
```c#
PropertyConfigurator<TProp> SetDisplayName(string displayName)
```
- **Parameters:**
- `displayName`: The display name for the property.
- **Returns:** `PropertyConfigurator<TProp>`
### List
Determines if the property should appear in the table, if not the property is also set to be not searchable.
```c#
PropertyConfigurator<TProp> List(bool list)
```
- **Parameters:**
- `list`: A boolean value to set if the property should appear in the table.
- **Returns:** `PropertyConfigurator<TProp>`
### IsSortable
Determines if the table can be sorted by the property.
```c#
PropertyConfigurator<TProp> IsSortable(bool sortable)
```
- **Parameters:**
- `sortable`: A boolean value to set if the property is sortable.
- **Returns:** `PropertyConfigurator<TProp>`
### IsSearchable
Determines if the property get taken into account for search results.
```c#
PropertyConfigurator<TProp> IsSearchable(bool searchable)
```
- **Parameters:**
- `searchable`: A boolean value to set if the property is searchable.
- **Returns:** `PropertyConfigurator<TProp>`
### SetDisplayedProperty
Determines if the value that should be displayed instead of the string representation of the type.
```c#
PropertyConfigurator<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression)
```
- **Type Parameters:**
- `TInnerProp`: The inner property type to display.
- **Parameters:**
- `propertyExpression`: The expression to determine the property.
- **Returns:** `PropertyConfigurator<TProp>`
### Format (Synchronous)
Determines the value that's displayed in the admin UI.
```c#
PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, string> formatter)
```
- **Parameters:**
- `formatter`: The function to format the value.
- **Returns:** `PropertyConfigurator<TProp>`
- **See Also:** [](#setdisplayedproperty)
### Format (Asynchronous)
Determines the value that's displayed in the admin UI.
```c#
PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, Task<string>> formatter)
```
- **Parameters:**
- `formatter`: The function to format the value.
- **Returns:** `PropertyConfigurator<TProp>`
### FormatEach (Synchronous)
Determines the value that's displayed for each entry in the list.
```c#
PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter)
```
- **Parameters:**
- `formatter`: The function to format the value for each entry.
- **Returns:** `PropertyConfigurator<TProp>`
### FormatEach (Asynchronous)
Determines the value that's displayed for each entry in the list.
```c#
PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, Task<string>> formatter)
```
- **Parameters:**
- `formatter`: The function to format the value for each entry.
- **Returns:** `PropertyConfigurator<TProp>`
### SetParser (Synchronous)
Determines the function used for parsing the value provided in the editor dialog to the actual property value.
```c#
PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, TProp> parser)
```
- **Parameters:**
- `parser`: The function to parse the value.
- **Returns:** `PropertyConfigurator<TProp>`
### SetParser (Asynchronous)
Determines the function used for parsing the value provided in the editor dialog to the actual property value.
```c#
PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, Task<TProp>> parser)
```
- **Parameters:**
- `parser`: The function to parse the value.
- **Returns:** `PropertyConfigurator<TProp>`
### SetEditable
Determines if the value can be edited in the admin UI. If true, the value can still be initially set, but not modified.
```c#
PropertyConfigurator<TProp> SetEditable(bool editable)
```
- **Parameters:**
- `editable`: A boolean value to set if the property is editable.
- **Returns:** `PropertyConfigurator<TProp>`
### SetCreatable
Determines if the initial value can be edited in the admin UI. If true the value will not be visible in the create dialog.
```c#
PropertyConfigurator<TProp> SetCreatable(bool creatable)
```
- **Parameters:**
- `creatable`: A boolean value to set if the property is creatable.
- **Returns:** `PropertyConfigurator<TProp>`
### DisplayValue
Determines if the value should be displayed in the admin UI (useful for secrets like passwords etc.).
```c#
PropertyConfigurator<TProp> DisplayValue(bool display)
```
- **Parameters:**
- `display`: A boolean value to set if the property value is displayed.
- **Returns:** `PropertyConfigurator<TProp>`
### IsTextArea
Determines if the admin UI should use a text area for modifying the value.
```c#
PropertyConfigurator<TProp> IsTextArea(bool textField)
```
- **Parameters:**
- `textField`: A boolean value to set if the property is a text area.
- **Returns:** `PropertyConfigurator<TProp>`
### SetTextAreaRows
Determines the initial size of the text area field.
```c#
PropertyConfigurator<TProp> SetTextAreaRows(int rows)
```
- **Parameters:**
- `rows`: The number of rows for the text area.
- **Returns:** `PropertyConfigurator<TProp>`
### SetValidator (Synchronous)
Determines the validator used for the property value before saving.
```c#
PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, IEnumerable<string>> validator)
```
- **Parameters:**
- `validator`: The function to validate the property value.
- **Returns:** `PropertyConfigurator<TProp>`
### SetValidator (Asynchronous)
Determines the validator used for the property value before saving.
```c#
PropertyConfigurator<TProp> SetValidator(Func<TProp?, IServiceProvider, Task<IEnumerable<string>>> validator)
```
- **Parameters:**
- `validator`: The function to validate the property value.
- **Returns:** `PropertyConfigurator<TProp>`
### SetOrderIndex
Determines the order index for the property in the admin UI.
```c#
PropertyConfigurator<TProp> SetOrderIndex(int index)
```
- **Parameters:**
- `index`: The order index for the property.
- **Returns:** `PropertyConfigurator<TProp>`
- **See Also:** [](TableConfig.md#setorderindex)
### SetDisplayLength
Sets the maximum character length displayed in the admin UI (not in the editor dialog).
```c#
PropertyConfigurator<TProp> SetDisplayLength(int maxLength)
```
- **Parameters:**
- `maxLength`: The maximum length of characters to be displayed.
- **Returns:** `PropertyConfigurator<TProp>`
### ForceRelation
Forces a property to be treated as a relation.
```c#
PropertyConfigurator<TProp> ForceRelation(bool isEnumerable = false, bool isRequired = true)
```
- **Parameters:**
- `isEnumerable`: Determines if it is possible to assign multiple objects to the property.
- `isRequired`: Determines if the property is nullable.
- **Returns:** `PropertyConfigurator<TProp>`

View File

@@ -0,0 +1,41 @@
# Table
On the table page you can view, edit, delete and create entries in that table.
You can use the many configuration methods provided by the [](TableConfig.md) to modify the look
of this page.
An example configuration could look something like this:
![table.png](table.png)
Here you can use the various buttons to interact with your entities.
## Data grid
The main aspect of this site is the data grid that displays all your entries of that table.
For performance reasons this data is paginated, you can **change the page** at the bottom of the screen
using either the arrow button or the page selector. You can **sort** your entries by clicking on
the name of the column. If you don't want a column to be sortable, you can configure this in the
[PropertyConfig](PropertyConfig.md#issortable).
## Search
The search bar will search through all entities in your database, not only the ones displayed.
Simply enter a search term and the search is performed automatically. You can configure the
columns that should be searchable in the [PropertyConfig](PropertyConfig.md#issearchable)
## Edit dialog
If you click on the pen button next to one of the entries, the edit dialog will appear,
it could look something like this:
![editor.png](editor.png)
You can modify the data of the selected entity based on your [PropertyConfigs](PropertyConfig.md).
The HopFrame can handle various property types like strings, numbers, enums, relations and many more.
### Validation
The HopFrame also supports input validation. By default, a required validation is automatically applied
to every property that's not nullable. You can change the validation behavior in the
[PropertyConfig](PropertyConfig.md#setvalidator-synchronous).

View File

@@ -0,0 +1,191 @@
# TableConfig
This configuration contains all configurations for the given table type.
## Configuration methods
### Ignore
Determines if the table should be ignored in the admin UI.
```c#
TableConfigurator<TModel> Ignore(bool ignore)
```
- **Parameters:**
- `ignore`: A boolean value to set if the table should be ignored.
- **Returns:** `TableConfigurator<TModel>`
### Property (With configurator)
Configures the property of the table using the provided configurator.
```c#
TableConfigurator<TModel> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression, Action<PropertyConfigurator<TProp>> configurator)
```
- **Parameters:**
- `propertyExpression`: Used for determining the property.
- `configurator`: Used for configuring the property.
- **Returns:** `TableConfigurator<TModel>`
- **See Also:** [](PropertyConfig.md)
### Property (Without configurator)
Configures the property of the table.
```c#
PropertyConfigurator<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression)
```
- **Parameters:**
- `propertyExpression`: Used for determining the property.
- **Returns:** `PropertyConfigurator<TProp>`
- **See Also:** [](PropertyConfig.md)
### AddVirtualProperty (With configurator)
Adds a virtual property to the table view and configures it using the provided configurator (this property will not appear in the editor).
```c#
TableConfigurator<TModel> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template, Action<PropertyConfigurator<string>> configurator)
```
- **Parameters:**
- `name`: The name of the virtual property.
- `template`: The template used for generating the property value.
- `configurator`: Used for configuring the virtual property.
- **Returns:** `TableConfigurator<TModel>`
- **See Also:** [](PropertyConfig.md)
### AddVirtualProperty (Synchronous)
Adds a virtual property to the table view (this property will not appear in the editor).
```c#
PropertyConfigurator<string> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template)
```
- **Parameters:**
- `name`: The name of the virtual property.
- `template`: The template used for generating the property value.
- **Returns:** `PropertyConfigurator<string>`
- **See Also:** [](PropertyConfig.md)
### AddVirtualProperty (Asynchronous)
Adds a virtual property to the table view (this property will not appear in the editor).
```c#
PropertyConfigurator<string> AddVirtualProperty(string name, Func<TModel, IServiceProvider, Task<string>> template)
```
- **Parameters:**
- `name`: The name of the virtual property.
- `template`: The template used for generating the property value.
- **Returns:** `PropertyConfigurator<string>`
- **See Also:** [](PropertyConfig.md)
### SetDisplayName
Determines the name for the table used in the admin UI and URL for the table page.
```c#
TableConfigurator<TModel> SetDisplayName(string name)
```
- **Parameters:**
- `name`: The display name for the table.
- **Returns:** `TableConfigurator<TModel>`
### SetDescription
Determines the description displayed in the admin UI.
```c#
TableConfigurator<TModel> SetDescription(string description)
```
- **Parameters:**
- `description`: The description for the table.
- **Returns:** `TableConfigurator<TModel>`
### SetOrderIndex
Determines the order index for the table in the admin UI.
```c#
TableConfigurator<TModel> SetOrderIndex(int index)
```
- **Parameters:**
- `index`: The order index for the table.
- **Returns:** `TableConfigurator<TModel>`
- **See Also:** [](PropertyConfig.md#setorderindex)
### SetViewPolicy
Determines the policy needed by a user in order to view the table.
```c#
TableConfigurator<TModel> SetViewPolicy(string policy)
```
- **Parameters:**
- `policy`: The view policy string.
- **Returns:** `TableConfigurator<TModel>`
### SetUpdatePolicy
Determines the policy needed by a user in order to edit the entries.
```c#
TableConfigurator<TModel> SetUpdatePolicy(string policy)
```
- **Parameters:**
- `policy`: The update policy string.
- **Returns:** `TableConfigurator<TModel>`
### SetCreatePolicy
Determines the policy needed by a user in order to create entries.
```c#
TableConfigurator<TModel> SetCreatePolicy(string policy)
```
- **Parameters:**
- `policy`: The create policy string.
- **Returns:** `TableConfigurator<TModel>`
### SetDeletePolicy
Determines the policy needed by a user in order to delete entries.
```c#
TableConfigurator<TModel> SetDeletePolicy(string policy)
```
- **Parameters:**
- `policy`: The delete policy string.
- **Returns:** `TableConfigurator<TModel>`

5
docs/Writerside/v.list Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE vars SYSTEM "https://resources.jetbrains.com/writerside/1.0/vars.dtd">
<vars>
<var name="product" value="Writerside"/>
</vars>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ihp SYSTEM "https://resources.jetbrains.com/writerside/1.0/ihp.dtd">
<ihp version="2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/writerside-cfg.xsd">
<topics dir="topics"/>
<images dir="images" web-path="images"/>
<categories src="c.list"/>
<vars src="v.list"/>
<instance src="hopframe.tree"/>
</ihp>

View File

@@ -0,0 +1,31 @@
using HopFrame.Core.Config;
namespace HopFrame.Core.Callbacks;
public static class CallbackTypes {
private const string Prefix = "HopFrame.";
private const string CreateEntryPrefix = Prefix + "Entry.Create.";
private const string UpdateEntryPrefix = Prefix + "Entry.Update.";
private const string DeleteEntryPrefix = Prefix + "Entry.Delete.";
public static string CreateEntry(TableConfig config) => CreateEntryPrefix + config.PropertyName;
public static string UpdateEntry(TableConfig config) => UpdateEntryPrefix + config.PropertyName;
public static string DeleteEntry(TableConfig config) => DeleteEntryPrefix + config.PropertyName;
public static string ConstructCallbackName(CallbackType type, TableConfig config) {
return type switch {
CallbackType.CreateEntry => CreateEntry(config),
CallbackType.UpdateEntry => UpdateEntry(config),
CallbackType.DeleteEntry => DeleteEntry(config),
_ => Prefix
};
}
}
public enum CallbackType {
CreateEntry = 0,
UpdateEntry = 1,
DeleteEntry = 2
}

View File

@@ -0,0 +1,7 @@
namespace HopFrame.Core.Callbacks;
public readonly struct HopCallbackHandler(string eventType, Func<object, IServiceProvider, Task> handler) {
public Guid Id { get; } = Guid.CreateVersion7();
public Func<object, IServiceProvider, Task> Handler { get; } = handler;
public string EventType { get; } = eventType;
}

View File

@@ -0,0 +1,14 @@
namespace HopFrame.Core.Callbacks;
public interface ICallbackEmitter {
Guid RegisterCallbackHandler(string @event, Func<object, IServiceProvider, Task> handler);
bool RemoveCallbackHandler(Guid id);
Task DispatchCallback(string @event, object argument = null!);
void RemoveAllCallbackHandlers(string @event);
void RemoveAllCallbackHandlers();
}

View File

@@ -2,12 +2,20 @@
namespace HopFrame.Core.Config;
public class DbContextConfig {
public interface ITableGroupConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; }
public HopFrameConfig ParentConfig { get; }
}
public class DbContextConfig : ITableGroupConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; } = new();
public HopFrameConfig ParentConfig { get; }
public DbContextConfig(Type context) {
public DbContextConfig(Type context, HopFrameConfig parentConfig) {
ContextType = context;
ParentConfig = parentConfig;
foreach (var property in ContextType.GetProperties()) {
if (!property.PropertyType.IsGenericType) continue;
@@ -24,7 +32,7 @@ public class DbContextConfig {
/// <summary>
/// A helper class for editing the <see cref="DbContextConfig"/>
/// </summary>
public class DbContextConfigurator<TDbContext>(DbContextConfig config) {
public sealed class DbContextConfigurator<TDbContext>(DbContextConfig config) {
/// <summary>
/// The Internal DbContext configuration that's modified by the helper functions

View File

@@ -1,24 +1,31 @@
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using HopFrame.Core.Callbacks;
using HopFrame.Core.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Core.Config;
public class HopFrameConfig {
public List<DbContextConfig> Contexts { get; } = new();
public List<ITableGroupConfig> Contexts { get; } = new();
public bool DisplayUserInfo { get; set; } = true;
public string? BasePolicy { get; set; }
public string? LoginPageRewrite { get; set; }
public List<HopCallbackHandler> Handlers { get; } = new();
}
/// <summary>
/// A helper class for editing the <see cref="HopFrameConfig"/>
/// </summary>
public class HopFrameConfigurator(HopFrameConfig config) {
public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollection collection = null!) {
/// <summary>
/// The Internal HopFrame configuration that's modified by the helper functions
/// </summary>
public HopFrameConfig InnerConfig { get; } = config;
public IServiceCollection ServiceCollection { get; } = collection;
/// <summary>
/// Adds all tables defined in the DbContext to the HopFrame ui and configures it using the provided configurator
/// </summary>
@@ -38,11 +45,64 @@ public class HopFrameConfigurator(HopFrameConfig config) {
/// <returns>The configurator used for the DbContext</returns>
/// <seealso cref="DbContextConfigurator{TDbContext}"/>
public DbContextConfigurator<TDbContext> AddDbContext<TDbContext>() where TDbContext : DbContext {
var context = new DbContextConfig(typeof(TDbContext));
var context = new DbContextConfig(typeof(TDbContext), InnerConfig);
InnerConfig.Contexts.Add(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>
/// Check if a context is already registered in the HopFrame
/// </summary>
/// <typeparam name="TDbContext">The context that should be checked</typeparam>
/// <returns>true if the context is already registered, false if not</returns>
public bool HasDbContext<TDbContext>() where TDbContext : DbContext {
return InnerConfig.Contexts.Any(context => context.ContextType == typeof(TDbContext));
}
/// <summary>
/// Returns a configurator for the context if it was already defined
/// </summary>
/// <typeparam name="TDbContext"></typeparam>
/// <returns>The configurator of the context if it already was defined, null if not</returns>
public DbContextConfigurator<TDbContext>? GetDbContext<TDbContext>() where TDbContext : DbContext {
var config = InnerConfig.Contexts
.OfType<DbContextConfig>()
.SingleOrDefault(context => context.ContextType == typeof(TDbContext));
if (config is null) return null;
return new DbContextConfigurator<TDbContext>(config);
}
/// <summary>
/// Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin ui
/// </summary>

View File

@@ -23,9 +23,37 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert
public bool IsRelation { get; internal set; }
public bool IsRequired { get; internal set; }
public bool IsEnumerable { get; internal set; }
public bool IsListingProperty { get; set; }
public bool IsVirtualProperty { get; set; }
public int Order { get; set; } = nthProperty;
public int DisplayLength { get; set; } = 32;
public virtual object? GetValue(object? source, IServiceProvider provider) {
return Info.GetValue(source);
}
public virtual void SetValue(object? source, object? value, IServiceProvider provider) {
Info.SetValue(source, value);
}
}
public sealed class VirtualPropertyConfig(TableConfig table, int nthProperty) : PropertyConfig(GetDummyProperty(), table, nthProperty) {
public string? DummyProperty { get; set; } = null;
public Func<object, string, IServiceProvider, Task>? VirtualParser { get; set; }
public override object? GetValue(object? source, IServiceProvider provider) {
return Formatter!.Invoke(source!, provider).Result;
}
public override void SetValue(object? source, object? value, IServiceProvider provider) {
VirtualParser?.Invoke(source!, (string)value!, provider).Wait();
}
private static PropertyInfo GetDummyProperty() {
return typeof(VirtualPropertyConfig)
.GetProperties()
.First(prop => prop.Name == nameof(DummyProperty));
}
}
/// <summary>
@@ -213,3 +241,28 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
return this;
}
}
public sealed class VirtualPropertyConfigurator<TModel>(VirtualPropertyConfig config) : PropertyConfigurator<string>(config) {
/// <summary>
/// Determines the function used for parsing the value provided in the editor dialog to the actual model value
/// </summary>
public VirtualPropertyConfigurator<TModel> SetVirtualParser(Action<TModel, string, IServiceProvider> parser) {
var cfg = InnerConfig as VirtualPropertyConfig;
cfg!.VirtualParser = (model, input, services) => {
parser.Invoke((TModel)model, input, services);
return Task.CompletedTask;
};
return this;
}
/// <inheritdoc cref="SetVirtualParser{TModel}(System.Action{TModel,string,System.IServiceProvider})"/>
public VirtualPropertyConfigurator<TModel> SetVirtualParser(Func<TModel, string, IServiceProvider, Task> parser) {
var cfg = InnerConfig as VirtualPropertyConfig;
cfg!.VirtualParser = (model, input, services) => parser.Invoke((TModel)model, input, services);
return this;
}
}

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

@@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Expressions;
using System.Reflection;
using HopFrame.Core.Callbacks;
namespace HopFrame.Core.Config;
@@ -10,10 +11,11 @@ public class TableConfig {
public string PropertyName { get; }
public string DisplayName { get; set; }
public string? Description { get; set; }
public DbContextConfig ContextConfig { get; }
public ITableGroupConfig ContextConfig { get; }
public bool Ignored { get; set; }
public int Order { get; set; }
internal bool Seeded { get; set; }
public bool ShowSearchSuggestions { get; set; } = true;
public string? ViewPolicy { get; set; }
public string? CreatePolicy { get; set; }
@@ -22,7 +24,7 @@ public class TableConfig {
public List<PropertyConfig> Properties { get; } = new();
public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) {
public TableConfig(ITableGroupConfig config, Type tableType, string propertyName, int nthTable) {
TableType = tableType;
PropertyName = propertyName;
ContextConfig = config;
@@ -51,7 +53,7 @@ public class TableConfig {
/// <summary>
/// A helper class for editing the <see cref="TableConfig"/>
/// </summary>
public class TableConfigurator<TModel>(TableConfig config) {
public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary>
/// The Internal property configuration that's modified by the helper functions
@@ -65,6 +67,13 @@ public class TableConfigurator<TModel>(TableConfig config) {
InnerConfig.Ignored = ignore;
return this;
}
/// <summary>
/// Determines if search suggestions should be displayed in the ui (Advanced Search)
/// </summary>
public TableConfigurator<TModel> ShowSearchSuggestions(bool show = true) {
InnerConfig.ShowSearchSuggestions = show;
return this;
}
/// <summary>
/// Configures the property of the table
@@ -75,7 +84,7 @@ public class TableConfigurator<TModel>(TableConfig config) {
public PropertyConfigurator<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
var info = GetPropertyInfo(propertyExpression);
var prop = InnerConfig.Properties
.Single(prop => prop.Info.Name == info.Name);
.Single(prop => prop.Info == info);
return new PropertyConfigurator<TProp>(prop);
}
@@ -98,25 +107,25 @@ public class TableConfigurator<TModel>(TableConfig config) {
/// <param name="template">The template used for generating the property value</param>
/// <returns>The configurator for the virtual property</returns>
/// <seealso cref="PropertyConfigurator{TProp}"/>
public PropertyConfigurator<string> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template) {
var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count) {
public VirtualPropertyConfigurator<TModel> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template) {
var prop = new VirtualPropertyConfig(InnerConfig, InnerConfig.Properties.Count) {
Name = name,
IsListingProperty = true,
IsVirtualProperty = true,
Formatter = (obj, provider) => Task.FromResult(template.Invoke((TModel)obj, provider))
};
InnerConfig.Properties.Add(prop);
return new PropertyConfigurator<string>(prop);
return new VirtualPropertyConfigurator<TModel>(prop);
}
/// <inheritdoc cref="AddVirtualProperty(string,System.Func{TModel,System.IServiceProvider,string})"/>
public PropertyConfigurator<string> AddVirtualProperty(string name, Func<TModel, IServiceProvider, Task<string>> template) {
var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count) {
public VirtualPropertyConfigurator<TModel> AddVirtualProperty(string name, Func<TModel, IServiceProvider, Task<string>> template) {
var prop = new VirtualPropertyConfig(InnerConfig, InnerConfig.Properties.Count) {
Name = name,
IsListingProperty = true,
IsVirtualProperty = true,
Formatter = (obj, provider) => template.Invoke((TModel)obj, provider)
};
InnerConfig.Properties.Add(prop);
return new PropertyConfigurator<string>(prop);
return new VirtualPropertyConfigurator<TModel>(prop);
}
/// <summary>
@@ -189,6 +198,36 @@ public class TableConfigurator<TModel>(TableConfig config) {
return this;
}
/// <summary>
/// Adds a callback handler of the provided type
/// </summary>
/// <param name="type">The type of callback that triggers the handler</param>
/// <param name="handler">The handler delegate</param>
public TableConfigurator<TModel> AddCallbackHandler(CallbackType type, Func<TModel, IServiceProvider, Task> handler) {
var eventName = CallbackTypes.ConstructCallbackName(type, InnerConfig);
var handlerStore = new HopCallbackHandler(eventName, (o, provider) => handler.Invoke((TModel)o, provider));
InnerConfig.ContextConfig.ParentConfig.Handlers.Add(handlerStore);
return this;
}
/// <summary>
/// Adds a callback handler of the provided type
/// </summary>
/// <param name="type">The type of callback that triggers the handler</param>
/// <param name="handler">The handler delegate</param>
public TableConfigurator<TModel> AddCallbackHandler(CallbackType type, Action<TModel, IServiceProvider> handler) {
var eventName = CallbackTypes.ConstructCallbackName(type, InnerConfig);
var handlerStore = new HopCallbackHandler(eventName, (o, provider) => {
handler.Invoke((TModel)o, provider);
return Task.CompletedTask;
});
InnerConfig.ContextConfig.ParentConfig.Handlers.Add(handlerStore);
return this;
}
internal static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
if (propertyLambda.Body is not MemberExpression member) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
@@ -198,7 +237,7 @@ public class TableConfigurator<TModel>(TableConfig config) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property.");
}
Type type = typeof(TSource);
var type = typeof(TSource);
if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
!type.IsSubclassOf(propInfo.ReflectedType)) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -19,7 +19,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -1,4 +1,5 @@
using HopFrame.Core.Services;
using HopFrame.Core.Callbacks;
using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -15,6 +16,8 @@ public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrameServices(this IServiceCollection services) {
services.AddScoped<IContextExplorer, ContextExplorer>();
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
services.AddScoped<ISearchExpressionBuilder, SearchExpressionBuilder>();
return services;
}

View File

@@ -7,4 +7,5 @@ public interface IContextExplorer {
public TableConfig? GetTable(string tableDisplayName);
public TableConfig? GetTable(Type tableEntity);
public ITableManager? GetTableManager(string tablePropertyName);
public ITableManager? GetTableManager(Type tableType);
}

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,13 +4,14 @@ using HopFrame.Core.Config;
namespace HopFrame.Core.Services;
public interface ITableManager {
public IQueryable<object> LoadPage(int page, int perPage = 20);
public Task<IEnumerable<object>> LoadPage(int page, int perPage = 20);
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20);
public Task<int> TotalPages(int perPage = 20);
public Task DeleteItem(object item);
public Task EditItem(object item);
public Task AddItem(object item);
public Task RevertChanges(object item);
public Task AddAll(IEnumerable<object> items);
public Task<object?> GetOne(object key);
public Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null);
}

View File

@@ -0,0 +1,39 @@
using HopFrame.Core.Config;
using HopFrame.Core.Callbacks;
namespace HopFrame.Core.Services.Implementations;
internal sealed class CallbackEmitter(IServiceProvider provider, HopFrameConfig config) : ICallbackEmitter {
public Guid RegisterCallbackHandler(string @event, Func<object, IServiceProvider, Task> handler) {
var handlerStore = new HopCallbackHandler(@event, handler);
config.Handlers.Add(handlerStore);
return handlerStore.Id;
}
public bool RemoveCallbackHandler(Guid id) {
var count = config.Handlers.RemoveAll(handler => handler.Id == id);
return count > 0;
}
public async Task DispatchCallback(string @event, object argument = null!) {
var handlers = config.Handlers.Where(handler => handler.EventType == @event);
var tasks = new List<Task>();
foreach (var handler in handlers) {
var task = handler.Handler.Invoke(argument, provider);
tasks.Add(task);
}
await Task.WhenAll(tasks);
}
public void RemoveAllCallbackHandlers(string @event) {
config.Handlers.RemoveAll(handler => handler.EventType == @event);
}
public void RemoveAllCallbackHandlers() {
config.Handlers.Clear();
}
}

View File

@@ -1,5 +1,4 @@
using System.Text.Json;
using HopFrame.Core.Config;
using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -45,11 +44,40 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName);
if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext;
if (dbContext is null) return null;
var repo = provider.GetService(context.ContextType);
if (repo is null) return null;
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, 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;
}
public ITableManager? GetTableManager(Type tableType) {
foreach (var context in config.Contexts) {
var table = context.Tables.FirstOrDefault(table => table.TableType == tableType);
if (table is null) continue;
var repo = provider.GetService(context.ContextType);
if (repo is null) return null;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
}
return null;
@@ -57,11 +85,12 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
private void SeedTableData(TableConfig table) {
if (table.Seeded) return;
if (table.ContextConfig is not DbContextConfig) return;
var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!;
var entity = dbContext.Model.FindEntityType(table.TableType)!;
foreach (var propertyConfig in table.Properties) {
if (propertyConfig.IsListingProperty) continue;
if (propertyConfig.IsVirtualProperty) continue;
if (propertyConfig.IsRelation) continue;
var prop = entity.FindProperty(propertyConfig.Info.Name);
@@ -93,7 +122,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
foreach (var property in entity.GetProperties()) {
var propConfig = table.Properties
.Where(prop => !prop.IsListingProperty)
.Where(prop => !prop.IsVirtualProperty)
.SingleOrDefault(prop => prop.Info == property.PropertyInfo);
if (propConfig is null || propConfig.IsRequired) continue;
propConfig.IsRequired = !property.IsNullable;

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.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Metadata;
using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
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 data = IncludeForeignKeys(table);
return data
return await data
.Skip(page * perPage)
.Take(perPage);
.Take(perPage)
.ToArrayAsync();
}
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
var table = context.Set<TModel>();
var all = IncludeForeignKeys(table)
.AsEnumerable()
.Where(item => ItemSearched(item, searchTerm))
.ToList();
return Task.FromResult((
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage),
(int)Math.Ceiling(all.Count / (double)perPage)));
var parameter = Expression.Parameter(typeof(TModel), "x");
var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
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) {
@@ -49,38 +64,24 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
await context.SaveChangesAsync();
}
public async Task RevertChanges(object item) {
var entry = context.Entry((TModel)item);
await entry.ReloadAsync();
if (entry.Collections.Any()) {
context.ChangeTracker.Clear();
}
public async Task AddAll(IEnumerable<object> items) {
var table = context.Set<TModel>();
await table.AddRangeAsync(items.Cast<TModel>());
await context.SaveChangesAsync();
}
private bool ItemSearched(TModel item, string searchTerm) {
foreach (var property in config.Properties) {
if (!property.Searchable) continue;
var value = property.Info.GetValue(item);
if (value is null) continue;
var strValue = value.ToString();
if (strValue?.Contains(searchTerm) == true)
return true;
}
return false;
public async Task<object?> GetOne(object key) {
var table = context.Set<TModel>();
return await table.FindAsync(key);
}
public async Task<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
if (item is null) return string.Empty;
if (prop.IsListingProperty)
if (prop.IsVirtualProperty)
return await prop.Formatter!.Invoke(item, provider);
var propValue = value ?? prop.Info.GetValue(item);
var propValue = value ?? prop.GetValue(item, provider);
if (propValue is null)
return string.Empty;
@@ -112,7 +113,7 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
if (innerConfig is null) return propValue.ToString()!;
var innerProp = innerConfig.Properties
.SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsListingProperty);
.SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsVirtualProperty);
if (innerProp is null) return propValue.ToString() ?? string.Empty;
return await DisplayProperty(propValue, innerProp);

View File

@@ -1,14 +1,17 @@
@rendermode InteractiveServer
@implements IDialogContentComponent<EditorDialogData>
@implements IDisposable
@using System.Collections
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using HopFrame.Web.Models
@using HopFrame.Web.Helpers
@using HopFrame.Web.Plugins
@using HopFrame.Web.Plugins.Events
<FluentDialogBody>
@foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) {
@foreach (var property in GetEditorProperties()) {
if (!_currentlyEditing && !property.Creatable) continue;
<div style="margin-bottom: 20px">
@@ -155,7 +158,7 @@
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
}
@foreach (var error in _validationErrors[property.Info.Name]) {
@foreach (var error in _validationErrors[property.Name]) {
<FluentLabel Color="@Color.Error">@error</FluentLabel>
}
</div>
@@ -169,6 +172,7 @@
@inject IHopFrameAuthHandler Handler
@inject IToastService Toasts
@inject IServiceProvider Provider
@inject IPluginOrchestrator PluginOrchestrator
@code {
[Parameter]
@@ -181,6 +185,7 @@
private ITableManager? _manager;
private readonly Dictionary<string, List<string>> _validationErrors = new();
private readonly List<PropertyChange> _changes = new();
private readonly CancellationTokenSource _tokenSource = new();
protected override void OnInitialized() {
_currentlyEditing = Content.CurrentObject is not null;
@@ -192,11 +197,16 @@
Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType);
foreach (var property in Content.Config.Properties) {
if (property.IsListingProperty) continue;
_validationErrors.Add(property.Info.Name, []);
_validationErrors.Add(property.Name, []);
}
}
private IEnumerable<PropertyConfig> GetEditorProperties() {
return Content.Config.Properties
.Where(prop => prop is not VirtualPropertyConfig { VirtualParser: null })
.OrderBy(prop => prop.Order);
}
private TValue? GetPropertyValue<TValue>(PropertyConfig config, object? listItem = null) {
if (!config.DisplayValue) return default;
if (Content.CurrentObject is null) return default;
@@ -310,12 +320,12 @@
private void ApplyChanges(object entry) {
foreach (var prop in Content.Config.Properties) {
var newValue = GetNewestValue(prop);
prop.Info.SetValue(entry, newValue);
prop.SetValue(entry, newValue, Provider);
}
}
private object? GetNewestValue(PropertyConfig config) {
var value = config.Info.GetValue(Content.CurrentObject);
var value = config.GetValue(Content.CurrentObject, Provider);
var change = _changes.LastOrDefault(c => c.Property == config.Info);
if (change is not null)
@@ -330,7 +340,7 @@
var relationType = config.Info.PropertyType;
if (config.IsEnumerable) {
relationType = config.Info.PropertyType.GetGenericArguments().First();
relationType = relationType.GetGenericArguments().First();
}
var relationTable = Explorer.GetTable(relationType);
@@ -361,9 +371,9 @@
return false;
foreach (var property in Content.Config.Properties) {
if (property.IsListingProperty) continue;
if (property.IsVirtualProperty) continue;
var errorList = _validationErrors[property.Info.Name];
var errorList = _validationErrors[property.Name];
errorList.Clear();
var value = GetNewestValue(property);
@@ -374,6 +384,14 @@
if (value is null && property.IsRequired)
errorList.Add($"{property.Name} is required");
var eventResult = await PluginOrchestrator.DispatchEvent(new ValidationEvent(this) {
Errors = errorList,
Property = property,
Table = Content.Config
}, _tokenSource.Token);
if (eventResult.IsCanceled) return false;
}
StateHasChanged();
@@ -390,7 +408,11 @@
return true;
}
private enum InputType {
public void Dispose() {
_tokenSource.Dispose();
}
public enum InputType {
Number,
Switch,
Date,

View File

@@ -0,0 +1,35 @@
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)">
<h3 style="margin-bottom: 0; display: flex; align-items: center; gap: 5px">
@if (Icon is not null) {
<FluentIcon Value="Icon" Color="Color.Neutral" />
}
@Title
</h3>
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@Subtitle</FluentLabel>
<span>@Description</span>
<FluentSpacer />
<div style="display: flex">
<FluentSpacer/>
<a href="@Href" style="display: inline-block">
<FluentButton>Open</FluentButton>
</a>
</div>
</FluentCard>
@code {
[Parameter]
public required string Title { get; set; }
[Parameter]
public string? Subtitle { get; set; }
[Parameter]
public required string Description { get; set; }
[Parameter]
public required string Href { get; set; }
[Parameter]
public Icon? Icon { get; set; }
}

View File

@@ -1,5 +1,6 @@
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using HopFrame.Web.Models
@using Microsoft.Extensions.DependencyInjection
@inherits LayoutComponentBase
@@ -39,10 +40,28 @@
@code {
internal static readonly List<CustomView> CustomViews = new();
protected override async Task OnInitializedAsync() {
var authorized = await Handler.IsAuthenticatedAsync(Config.BasePolicy);
var currentUri = "/" + Navigator.ToBaseRelativePath(Navigator.Uri);
if (authorized) {
foreach (var view in CustomViews.Where(view => !string.IsNullOrWhiteSpace(view.Policy))) {
switch (view.LinkMatch) {
case NavLinkMatch.All when currentUri != view.Url:
case NavLinkMatch.Prefix when !currentUri.StartsWith(view.Url):
continue;
}
authorized = await Handler.IsAuthenticatedAsync(view.Policy);
break;
}
}
if (!authorized) {
Navigator.NavigateTo((Config.LoginPageRewrite ?? "/login") + "?redirect=/" + Navigator.ToBaseRelativePath(Navigator.Uri), true);
Navigator.NavigateTo((Config.LoginPageRewrite ?? "/login") + "?redirect=" + currentUri, true);
}
}

View File

@@ -1,5 +1,6 @@
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using HopFrame.Web.Models
<FluentAppBar Orientation="Orientation.Vertical" PopoverShowSearch="false" Style="background-color: var(--neutral-layer-2); height: auto">
<FluentAppBarItem Href="/admin"
@@ -11,6 +12,15 @@
<br>
@foreach (var view in _views) {
<FluentAppBarItem Href="@view.Url"
Match="@view.LinkMatch"
IconActive="GetLinkIcon(view, IconVariant.Filled)"
IconRest="GetLinkIcon(view, IconVariant.Regular)"
Text="@view.Name"
Style="margin-top: 0.25rem"/>
}
@foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) {
<FluentAppBarItem Href="@("/admin/" + table.ToLower())"
Match="NavLinkMatch.All"
@@ -27,6 +37,7 @@
@code {
private readonly List<TableConfig> _tables = [];
private readonly List<CustomView> _views = [];
protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) {
@@ -34,6 +45,21 @@
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table);
}
foreach (var view in HopFrameLayout.CustomViews) {
if (!await Handler.IsAuthenticatedAsync(view.Policy)) continue;
_views.Add(view);
}
}
internal static Icon GetLinkIcon(CustomView view, IconVariant variant) {
var info = new IconInfo {
Name = view.Icon,
Variant = variant,
Size = IconSize.Size24
};
return info.GetInstance();
}
}

View File

@@ -1,28 +1,31 @@
@page "/admin"
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using HopFrame.Web.Models
@layout HopFrameLayout
<PageTitle>HopFrame</PageTitle>
<div style="padding: 1.5rem 1.5rem;">
<h2>Tables</h2>
<h2>Pages</h2>
<FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem">
@foreach (var table in _tables.OrderBy(t => t.Order)) {
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)">
<h3 style="margin-bottom: 0;">@table.DisplayName</h3>
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@table.ViewPolicy</FluentLabel>
<span>@table.Description</span>
<FluentSpacer />
<div style="display: flex">
<FluentSpacer/>
@foreach (var view in _views) {
<HopFrameCard
Title="@view.Name"
Subtitle="@view.Policy"
Description="@view.Description"
Href="@view.Url"
Icon="HopFrameSideMenu.GetLinkIcon(view, IconVariant.Regular)"/>
}
<a href="@("/admin/" + table.DisplayName.ToLower())" style="display: inline-block">
<FluentButton>Open</FluentButton>
</a>
</div>
</FluentCard>
@foreach (var table in _tables.OrderBy(t => t.Order)) {
<HopFrameCard
Title="@table.DisplayName"
Subtitle="@table.ViewPolicy"
Description="@table.Description"
Href="@("/admin/" + table.DisplayName.ToLower())"
Icon="new Icons.Regular.Size24.Database()"/>
}
</FluentStack>
</div>
@@ -33,6 +36,7 @@
@code {
private readonly List<TableConfig> _tables = [];
private readonly List<CustomView> _views = [];
protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) {
@@ -40,6 +44,11 @@
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table);
}
foreach (var view in HopFrameLayout.CustomViews) {
if (!await Handler.IsAuthenticatedAsync(view.Policy)) continue;
_views.Add(view);
}
}
}

View File

@@ -4,10 +4,13 @@
@implements IDisposable
@using HopFrame.Core.Config
@using HopFrame.Core.Callbacks
@using HopFrame.Core.Services
@using HopFrame.Web.Models
@using HopFrame.Web.Plugins
@using HopFrame.Web.Plugins.Events
@using HopFrame.Web.Services
@using Microsoft.JSInterop
@using Microsoft.EntityFrameworkCore
@if (!DisplaySelection) {
<PageTitle>@_config?.DisplayName</PageTitle>
@@ -18,7 +21,7 @@
<div style="display: flex; flex-direction: column; height: 100%">
<FluentToolbar Class="hopframe-toolbar">
<h3>@_config?.DisplayName</h3>
@if (!DisplaySelection) {
@if (!DisplaySelection && _buttonToggles.ShowRefreshButton) {
<FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload"
@@ -28,18 +31,54 @@
</FluentButton>
}
<FluentSpacer />
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" />
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopLeft)) {
<FluentButton
IconStart="@(button.Icon?.GetInstance())"
OnClick="() => button.Handler.Invoke(null!, _config!)">
@button.Title
</FluentButton>
}
@if (_hasCreatePolicy && DisplayActions) {
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
<FluentSpacer />
<div
style="position: relative; height: 32px"
class="hopframe-search">
<FluentSearch
@ref="_searchBox"
@oninput="OnSearch"
@onchange="OnSearch"
@onfocusin="() => { SearchFocus(); UpdateSearchSuggestions(); }"
@onfocusout="SearchUnfocus"
Style="width: 500px"/>
@if (_isSearchActive && _searchSuggestions.Count > 0) {
<FluentListbox
TOption="string"
Items="_searchSuggestions"
SelectedOptionChanged="SearchSuggestionSelected"
@onfocusin="SearchFocus"
@onfocusout="SearchUnfocus"/>
}
</div>
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
<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)) {
<FluentButton
IconStart="@(button.Icon?.GetInstance())"
OnClick="() => button.Handler.Invoke(null!, _config!)">
@button.Title
</FluentButton>
}
</FluentToolbar>
<FluentProgress Visible="_loading" Width="100%" />
<div style="display: flex; overflow-y: auto; flex-grow: 1">
<div style="flex-grow: 1">
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()">
<FluentDataGrid Items="CurrentlyDisplayedModels.AsQueryable()">
@if (DisplaySelection) {
<SelectColumn
TGridItem="object"
@@ -60,13 +99,19 @@
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@if (_hasUpdatePolicy) {
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) {
<FluentButton OnClick="() => button.Handler.Invoke(context, _config!)">
<FluentIcon Value="@(button.Icon!.GetInstance())" />
</FluentButton>
}
@if (_hasUpdatePolicy && _buttonToggles.ShowEditButton) {
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(context); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
</FluentButton>
}
@if (_hasDeletePolicy) {
@if (_hasDeletePolicy && _buttonToggles.ShowDeleteButton) {
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(context); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
</FluentButton>
@@ -110,13 +155,34 @@
}
removeBg();
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? '';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}
window.triggerClick = (elt) => elt.click();
</script>
<FluentToastProvider MaxToastCount="10" />
<InputFile style="display: none" @ref="FileInputElement" OnChange="OnInputFiles"></InputFile>
@inject IContextExplorer Explorer
@inject NavigationManager Navigator
@inject IJSRuntime Js
@inject IDialogService Dialogs
@inject IHopFrameAuthHandler Handler
@inject ICallbackEmitter Emitter
@inject IPluginOrchestrator PluginOrchestrator
@inject ISearchSuggestionProvider SearchSuggestions
@code {
@@ -141,11 +207,14 @@
private TableConfig? _config;
private ITableManager? _manager;
private object[] _currentlyDisplayedModels = [];
public object[] CurrentlyDisplayedModels = [];
private int _currentPage;
private int _totalPages;
private string? _searchTerm;
private bool _loading;
private bool _isSearchActive;
private IList<string> _searchSuggestions = [];
private FluentSearch? _searchBox;
private bool _hasUpdatePolicy;
private bool _hasDeletePolicy;
@@ -153,7 +222,14 @@
private bool _allSelected;
private readonly CancellationTokenSource _tokenSource = new();
private List<PluginButton> _pluginButtons = new();
private DefaultButtonToggles _buttonToggles = new();
internal static HopFrameTablePage? CurrentInstance { get; private set; }
protected override void OnInitialized() {
CurrentInstance = this;
_config ??= Explorer.GetTable(TableDisplayName);
if (_config is null || (_config.Ignored && DialogData is null)) {
@@ -167,12 +243,19 @@
return;
}
var eventResult = await PluginOrchestrator.DispatchEvent(new TableInitializedEvent(this) {
Table = _config!
});
if (eventResult.IsCanceled) return;
_pluginButtons = eventResult.PluginButtons;
_buttonToggles = eventResult.DefaultButtons;
_hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy);
_hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy);
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync();
CurrentlyDisplayedModels = (await _manager!.LoadPage(_currentPage, PerPage)).ToArray();
_totalPages = await _manager.TotalPages(PerPage);
}
@@ -187,6 +270,7 @@
public void Dispose() {
_searchCancel.Dispose();
_tokenSource.Dispose();
}
private CancellationTokenSource _searchCancel = new();
@@ -195,16 +279,71 @@
_searchTerm = eventArgs.Value?.ToString();
if (_searchTerm is null) return;
_searchCancel = new();
UpdateSearchSuggestions();
await Task.Delay(500, _searchCancel.Token);
var eventResult = await PluginOrchestrator.DispatchEvent(new SearchEvent(this) {
SearchTerm = _searchTerm,
Table = _config!,
CurrentPage = _currentPage
}, _tokenSource.Token);
if (eventResult.IsCanceled) {
if (eventResult.SearchResult is null) return;
CurrentlyDisplayedModels = eventResult.SearchResult.ToArray();
_totalPages = eventResult.TotalPages;
return;
}
_searchTerm = eventResult.SearchTerm;
await Reload();
}
private async Task 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() {
_loading = true;
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this) {
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) {
_loading = false;
return;
}
if (!string.IsNullOrEmpty(_searchTerm)) {
(var query, _totalPages) = await _manager!.Search(_searchTerm, 0, PerPage);
_currentlyDisplayedModels = query.ToArray();
(var query, _totalPages) = await _manager!.Search(_searchTerm, _currentPage, PerPage);
CurrentlyDisplayedModels = query.ToArray();
}
else {
await OnInitializedAsync();
@@ -212,7 +351,16 @@
_loading = false;
}
private async Task ChangePage(int page) {
public async Task ChangePage(int page) {
var eventResult = await PluginOrchestrator.DispatchEvent(new PageChangeEvent(this) {
CurrentPage = _currentPage,
NewPage = page,
TotalPages = _totalPages,
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) return;
page = eventResult.NewPage;
if (page < 0 || page > _totalPages - 1) return;
_currentPage = page;
await Reload();
@@ -224,11 +372,18 @@
return;
}
var eventResult = await PluginOrchestrator.DispatchEvent(new DeleteEntryEvent(this) {
Entity = element,
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) return;
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
var result = await dialog.Result;
if (result.Cancelled) return;
await _manager!.DeleteItem(element);
await Emitter.DispatchCallback(CallbackTypes.DeleteEntry(_config!), element);
await Reload();
}
@@ -238,6 +393,22 @@
return;
}
HopFrameTablePageEventArgs eventArgs;
if (element is null) {
eventArgs = new CreateEntryEvent(this) {
Table = _config!
};
}
else {
eventArgs = new UpdateEntryEvent(this) {
Table = _config!,
Entity = element
};
}
var eventResult = await PluginOrchestrator.DispatchEvent(eventArgs, _tokenSource.Token);
if (eventResult.IsCanceled) return;
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
TrapFocus = false
});
@@ -246,23 +417,35 @@
if (result.Cancelled) return;
if (element is null)
if (element is null) {
await _manager!.AddItem(data!.CurrentObject!);
else
await Emitter.DispatchCallback(CallbackTypes.CreateEntry(_config!), data.CurrentObject!);
}
else {
await _manager!.EditItem(data!.CurrentObject!);
await Emitter.DispatchCallback(CallbackTypes.UpdateEntry(_config!), data.CurrentObject!);
}
await Reload();
}
private void SelectItem(object item, bool selected) {
var eventResult = PluginOrchestrator.DispatchEvent(new SelectEntryEvent(this) {
Entity = item,
Selected = selected,
Table = _config!
}, _tokenSource.Token).Result;
if (eventResult.IsCanceled) return;
selected = eventResult.Selected;
if (!selected)
DialogData!.SelectedObjects.Remove(item);
else DialogData!.SelectedObjects.Add(item);
}
private void SelectAll() {
var selected = _currentlyDisplayedModels.All(DialogData!.SelectedObjects.Contains);
foreach (var displayedModel in _currentlyDisplayedModels) {
var selected = CurrentlyDisplayedModels.All(DialogData!.SelectedObjects.Contains);
foreach (var displayedModel in CurrentlyDisplayedModels) {
SelectItem(displayedModel, !selected);
}
_allSelected = selected;
@@ -276,4 +459,22 @@
return display;
}
public InputFile? FileInputElement;
public Func<IEnumerable<IBrowserFile>, Task>? OnFileUpload;
private async Task OnInputFiles(InputFileChangeEventArgs e) {
if (OnFileUpload is null) return;
if (e.FileCount == 1) {
await OnFileUpload.Invoke([e.File]);
}
else {
await OnFileUpload.Invoke(e.GetMultipleFiles());
}
}
public void RequestRender() {
StateHasChanged();
}
}

View File

@@ -20,3 +20,13 @@
place-items: center;
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

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -27,9 +27,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0"/>
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.13.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.13.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,68 @@
using System.Reflection;
using HopFrame.Core.Config;
using HopFrame.Web.Components.Layout;
using HopFrame.Web.Models;
using HopFrame.Web.Plugins;
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Internal;
namespace HopFrame.Web;
public static class HopFrameConfiguratorExtensions {
/// <summary>
/// Creates an entry to the side menu and dashboard with a custom url
/// </summary>
/// <param name="configurator">The configurator for the HopFrame config that is being created</param>
/// <param name="name">The name of the navigation entry</param>
/// <param name="url">The target url of the navigation entry</param>
public static CustomViewConfigurator AddCustomView(this HopFrameConfigurator configurator, string name, string url) {
var view = new CustomView {
Name = name,
Url = url
};
HopFrameLayout.CustomViews.Add(view);
return new CustomViewConfigurator(view);
}
/// <param name="configuratorDelegate">The delegate for configuring the view</param>
/// <inheritdoc cref="AddCustomView(HopFrame.Core.Config.HopFrameConfigurator,string,string)"/>
public static HopFrameConfigurator AddCustomView(this HopFrameConfigurator configurator, string name, string url, Action<CustomViewConfigurator> configuratorDelegate) {
var viewConfigurator = AddCustomView(configurator, name, url);
configuratorDelegate.Invoke(viewConfigurator);
return configurator;
}
/// <summary>
/// Registers a plugin
/// </summary>
/// <param name="configurator">The configurator for the HopFrame config that is being created</param>
/// <typeparam name="TPlugin">The plugin that should be registered</typeparam>
public static HopFrameConfigurator AddPlugin<TPlugin>(this HopFrameConfigurator configurator) where TPlugin : class {
PluginOrchestrator.RegisterPlugin(configurator.ServiceCollection, typeof(TPlugin));
var methods = typeof(TPlugin).GetMethods()
.Where(method => method.IsStatic)
.Where(method => method.GetCustomAttributes(true)
.Any(attr => attr is PluginConfiguratorAttribute))
.Where(method => method.GetParameters().Length < 2);
foreach (var method in methods) {
if (method.GetParameters().Length > 0)
method.Invoke(null, [configurator]);
else method.Invoke(null, []);
}
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) {
configurator.AddPlugin<ExporterPlugin>();
return configurator;
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Models;
public sealed class CustomView {
public required string Name { get; init; }
public string? Description { get; set; }
public string? Policy { get; set; }
public required string Url { get; init; }
public string Icon { get; set; } = "Window";
public NavLinkMatch LinkMatch { get; set; } = NavLinkMatch.All;
}
public sealed class CustomViewConfigurator(CustomView view) {
public CustomView InnerConfig { get; } = view;
/// <summary>
/// Sets the description displayed in the dashboard
/// </summary>
/// <param name="description">The desired description</param>
public CustomViewConfigurator SetDescription(string description) {
InnerConfig.Description = description;
return this;
}
/// <summary>
/// Sets the policy needed in order to access the view
/// </summary>
/// <param name="policy">The desired policy</param>
public CustomViewConfigurator SetPolicy(string policy) {
InnerConfig.Policy = policy;
return this;
}
/// <summary>
/// Sets the icon displayed in the sidebar
/// </summary>
/// <param name="icon">The desired <see href="https://www.fluentui-blazor.net/Icon#explorer">fluent-icon</see></param>
public CustomViewConfigurator SetIcon(string icon) {
InnerConfig.Icon = icon;
return this;
}
/// <summary>
/// Sets the rule for the sidebar to determine if the link is active
/// </summary>
/// <param name="match">The desired match rule</param>
public CustomViewConfigurator SetLinkMatch(NavLinkMatch match) {
InnerConfig.LinkMatch = match;
return this;
}
}

View File

@@ -0,0 +1,4 @@
namespace HopFrame.Web.Plugins.Annotations;
[AttributeUsage(AttributeTargets.Method)]
public class EventHandlerAttribute : Attribute;

View File

@@ -0,0 +1,8 @@
namespace HopFrame.Web.Plugins.Annotations;
/// <summary>
/// Configures the method as a plugin configurator, so the method gets called, when the plugin is registered.
/// Only works on static methods
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class PluginConfiguratorAttribute : Attribute;

View File

@@ -0,0 +1,18 @@
using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public sealed class DeleteEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public required object Entity { get; init; }
}
public sealed class CreateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender);
public sealed class UpdateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public required object Entity { get; init; }
}
public sealed class SelectEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public required object Entity { get; init; }
public required bool Selected { get; set; }
}

View File

@@ -0,0 +1,27 @@
using HopFrame.Core.Config;
using HopFrame.Web.Components.Dialogs;
using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public abstract class HopFrameEventArgs(object internalSender) {
internal object InternalSender { get; } = internalSender;
public bool IsCanceled { get; protected set; }
public void SetCancelled(bool canceled) => IsCanceled = canceled;
}
public abstract class HopFrameEventArgs<TSender>(TSender sender) : HopFrameEventArgs(sender) where TSender : class {
public TSender Sender => (TSender)InternalSender;
}
public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender)
: HopFrameEventArgs<HopFrameTablePage>(sender) {
public required TableConfig Table { get; init; }
}
public abstract class HopFrameEditorEventArgs(HopFrameEditor sender)
: HopFrameEventArgs<HopFrameEditor>(sender) {
public required TableConfig Table { get; init; }
}

View File

@@ -0,0 +1,9 @@
using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public sealed class PageChangeEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public required int CurrentPage { get; init; }
public required int TotalPages { get; init; }
public required int NewPage { get; set; }
}

View File

@@ -0,0 +1,9 @@
using System.Reflection;
namespace HopFrame.Web.Plugins.Events;
internal sealed class PluginEventContainer {
public required MethodInfo Handler { get; init; }
public required Type EventType { get; init; }
public required bool IsAwaitable { get; init; }
}

View File

@@ -0,0 +1,7 @@
using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public sealed class ReloadEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
}

View File

@@ -0,0 +1,22 @@
using System.Collections;
using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public sealed class SearchEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public required string SearchTerm { get; set; }
public required int CurrentPage { get; init; }
internal IEnumerable<object>? SearchResult { get; set; }
internal int TotalPages { get; set; }
/// <summary>
/// Sets the new search result that is being displayed<br />
/// The event needs to be canceled in order for the custom search results to appear
/// </summary>
/// <param name="result">The current page of search results</param>
/// <param name="totalPages">The total pages of search results</param>
public void SetSearchResult(IEnumerable result, int totalPages) {
SearchResult = result.OfType<object>();
TotalPages = totalPages;
}
}

View File

@@ -0,0 +1,101 @@
using HopFrame.Core.Config;
using HopFrame.Web.Components.Pages;
using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Plugins.Events;
public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public List<PluginButton> PluginButtons { get; } = new();
public DefaultButtonToggles DefaultButtons { get; set; } = new();
public void AddPageButton(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) {
PluginButtons.Add(new() {
Title = title,
Icon = icon,
Position = pushRight ? PluginButtonPosition.TopRight : PluginButtonPosition.TopLeft,
Handler = (_, _) => callback.Invoke()
});
}
public void AddPageButton(string title, Action callback, bool pushRight = false, IconInfo? icon = null) {
AddPageButton(title, () => {
callback.Invoke();
return Task.CompletedTask;
}, pushRight, icon);
}
public void AddEntityButton(IconInfo icon, Func<object, TableConfig, Task> callback) {
PluginButtons.Add(new() {
Icon = icon,
Position = PluginButtonPosition.OnEntry,
Handler = callback
});
}
public void AddEntityButton(IconInfo icon, Action<object, TableConfig> callback) {
AddEntityButton(icon, (obj, cfg) => {
callback.Invoke(obj, cfg);
return Task.CompletedTask;
});
}
public void AddEntityButton<TEntity>(IconInfo icon, Func<TEntity, TableConfig, Task> callback) {
PluginButtons.Add(new() {
Icon = icon,
Position = PluginButtonPosition.OnEntry,
Handler = (obj, cfg) => callback.Invoke((TEntity)obj, cfg),
TableFilter = typeof(TEntity)
});
}
public void AddEntityButton<TEntity>(IconInfo icon, Action<TEntity, TableConfig> callback) {
AddEntityButton<TEntity>(icon, (obj, cfg) => {
callback.Invoke(obj, cfg);
return Task.CompletedTask;
});
}
public void AddPageButton<TEntity>(string title, Func<Task> callback, bool pushRight = false, IconInfo? icon = null) {
PluginButtons.Add(new() {
Title = title,
Icon = icon,
Position = pushRight ? PluginButtonPosition.TopRight : PluginButtonPosition.TopLeft,
Handler = (_, _) => callback.Invoke(),
TableFilter = typeof(TEntity)
});
}
public void AddPageButton<TEntity>(string title, Action callback, bool pushRight = false, IconInfo? icon = null) {
AddPageButton<TEntity>(title, () => {
callback.Invoke();
return Task.CompletedTask;
}, pushRight, icon);
}
}
public struct PluginButton {
public PluginButtonPosition Position { get; set; }
public Func<object, TableConfig, Task> Handler { get; set; }
public string? Title { get; set; }
public IconInfo? Icon { get; set; }
public Type? TableFilter { get; set; }
internal bool IsForTable(TableConfig? config) {
if (config is null) return false;
if (TableFilter is null) return true;
return config.TableType == TableFilter;
}
}
public enum PluginButtonPosition {
TopLeft = 0,
TopRight = 1,
OnEntry = 2
}
public struct DefaultButtonToggles() {
public bool ShowRefreshButton { get; set; } = true;
public bool ShowAddEntityButton { get; set; } = true;
public bool ShowDeleteButton { get; set; } = true;
public bool ShowEditButton { get; set; } = true;
}

View File

@@ -0,0 +1,9 @@
using HopFrame.Core.Config;
using HopFrame.Web.Components.Dialogs;
namespace HopFrame.Web.Plugins.Events;
public sealed class ValidationEvent(HopFrameEditor sender) : HopFrameEditorEventArgs(sender) {
public required IList<string> Errors { get; init; }
public required PropertyConfig Property { get; init; }
}

View File

@@ -0,0 +1,7 @@
using HopFrame.Web.Plugins.Events;
namespace HopFrame.Web.Plugins;
public interface IPluginOrchestrator {
public Task<TEvent> DispatchEvent<TEvent>(TEvent @event, CancellationToken ct = new()) where TEvent : HopFrameEventArgs;
}

View File

@@ -0,0 +1,211 @@
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Text;
using HopFrame.Core.Config;
using HopFrame.Core.Services;
using HopFrame.Web.Components.Pages;
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Events;
using HopFrame.Web.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Plugins.Internal;
internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService toasts, IFileService files) {
public const char Separator = ';';
[EventHandler]
public void OnInit(TableInitializedEvent e) {
if (e.Sender.DialogData is not null) return;
e.AddPageButton("Export", () => Export(e.Table), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowUpload());
e.AddPageButton("Import", () => Import(e.Table, e.Sender), icon: new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.ArrowDownload());
}
private async Task Export(TableConfig table) {
var manager = explorer.GetTableManager(table.PropertyName);
if (manager is null) {
toasts.ShowError("Data could not be exported!");
return;
}
var data = await manager
.LoadPage(0, int.MaxValue);
var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray();
var csv = new StringBuilder(string.Join(Separator, properties.Select(prop => prop.Info.Name)) + '\n');
foreach (var entry in data) {
var row = new List<string>();
foreach (var property in properties) {
row.Add(FormatProperty(property, entry));
}
csv.Append(string.Join(Separator, row) + '\n');
}
var result = csv.ToString();
await files.DownloadFile($"{table.DisplayName}.csv", Encoding.UTF8.GetBytes(result));
}
private async Task Import(TableConfig table, HopFrameTablePage target) {
var file = await files.UploadFile();
var stream = file.OpenReadStream();
var reader = new StreamReader(stream);
var properties = table.Properties.Where(prop => !prop.IsVirtualProperty).ToArray();
var data = await reader.ReadToEndAsync();
var rows = data.Split('\n');
reader.Dispose();
await stream.DisposeAsync();
var headerProps = rows.First().Split(Separator);
if (!headerProps.Any(h => properties.Any(prop => prop.Info.Name == h))) {
toasts.ShowError("Table header in csv is not valid!");
return;
}
var elements = new List<object>();
for (int rowIndex = 1; rowIndex < rows.Length; rowIndex++) {
var row = rows[rowIndex];
if (string.IsNullOrWhiteSpace(row)) continue;
var element = Activator.CreateInstance(table.TableType)!;
var rowValues = row.Split(Separator);
for (int i = 0; i < headerProps.Length; i++) {
var property = properties.FirstOrDefault(prop => prop.Info.Name == headerProps[i]);
if (property is null) continue;
object? value = rowValues[i];
if (string.IsNullOrWhiteSpace((string)value)) continue;
if (property.IsEnumerable) {
if (!property.Info.PropertyType.IsGenericType) continue;
var formattedEnumerable = (string)value;
if (formattedEnumerable == "[]") continue;
var values = formattedEnumerable
.TrimStart('[')
.TrimEnd(']')
.Split(',');
var addMethod = property.Info.PropertyType.GetMethod("Add");
if (addMethod is null) continue;
var tableType = property.Info.PropertyType.GenericTypeArguments[0];
var relationManager = explorer.GetTableManager(tableType);
var primaryKeyType = GetPrimaryKeyType(tableType);
if (relationManager is null || primaryKeyType is null) continue;
var enumerable = Activator.CreateInstance(property.Info.PropertyType);
foreach (var key in values) {
var entry = await relationManager.GetOne(ParseString(key, primaryKeyType)!);
if (entry is null) continue;
addMethod.Invoke(enumerable, [entry]);
}
property.Info.SetValue(element, enumerable);
continue;
}
if (property.IsRelation) {
var relationManager = explorer.GetTableManager(property.Info.PropertyType);
var relationPrimaryKeyType = GetPrimaryKeyType(property.Info.PropertyType);
if (relationManager is null || relationPrimaryKeyType is null) continue;
value = await relationManager.GetOne(ParseString((string)value, relationPrimaryKeyType)!);
}
else if (property.Info.PropertyType == typeof(Guid)) {
var success = Guid.TryParse((string)value, out var guid);
if (success) value = guid;
else toasts.ShowError($"'{value}' is not a valid guid");
}
else {
value = ParseString((string)value, property.Info.PropertyType);
}
property.Info.SetValue(element, value);
}
elements.Add(element);
}
var manager = explorer.GetTableManager(table.PropertyName);
if (manager is null) {
toasts.ShowError("Data could not be imported!");
return;
}
await manager.AddAll(elements);
await target.Reload();
}
private string FormatProperty(PropertyConfig property, object entity) {
var value = property.Info.GetValue(entity);
if (value is null)
return string.Empty;
if (property.IsEnumerable) {
var enumerable = (IEnumerable)value;
return '[' + string.Join(',', enumerable.OfType<object>().Select(o => SelectPrimaryKey(o, property) ?? o.ToString())) + ']';
}
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();
}
}
return entity
.GetType()
.GetProperties()
.FirstOrDefault(prop => prop
.GetCustomAttributes(true)
.Any(attr => attr is KeyAttribute))?
.GetValue(entity)?
.ToString();
}
private Type? GetPrimaryKeyType(Type tableType) {
var table = explorer.GetTable(tableType);
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.PropertyType;
}
return tableType
.GetProperties()
.FirstOrDefault(prop => prop
.GetCustomAttributes(true)
.Any(attr => attr is KeyAttribute))?
.PropertyType;
}
private object? ParseString(string input, Type targetType) {
try {
var parseMethod = targetType
.GetMethods()
.Where(method => method.Name.StartsWith("Parse"))
.FirstOrDefault(method => method.GetParameters().SingleOrDefault()?.ParameterType == typeof(string));
if (parseMethod is not null)
return parseMethod.Invoke(null, [input]);
return Convert.ChangeType(input, targetType);
}
catch (Exception) {
return null;
}
}
}

View File

@@ -0,0 +1,57 @@
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Events;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web.Plugins.Internal;
internal sealed class PluginOrchestrator(IServiceProvider services) : IPluginOrchestrator {
public static void RegisterPlugin(IServiceCollection collection, Type plugin) {
var methods = plugin.GetMethods()
.Where(method => method.GetCustomAttributes(true)
.Any(attr => attr is EventHandlerAttribute));
foreach (var method in methods) {
var awaitable = method.ReturnType.IsAssignableFrom(typeof(Task));
var eventType = method
.GetParameters()
.FirstOrDefault(param => param.ParameterType.IsAssignableTo(typeof(HopFrameEventArgs)))?.ParameterType;
if (eventType is null) continue;
var container = new PluginEventContainer {
EventType = eventType,
IsAwaitable = awaitable,
Handler = method
};
collection.AddSingleton(container);
collection.AddScoped(plugin);
}
}
public async Task<TEvent> DispatchEvent<TEvent>(TEvent @event, CancellationToken ct = new()) where TEvent : HopFrameEventArgs {
var eventContainers = services.GetRequiredService<IEnumerable<PluginEventContainer>>()
.Where(container => container.EventType == typeof(TEvent));
var eventType = typeof(TEvent);
var tokenType = typeof(CancellationToken);
foreach (var container in eventContainers) {
var plugin = services.GetRequiredService(container.Handler.DeclaringType!);
var parameters = new List<object?>();
foreach (var parameter in container.Handler.GetParameters()) {
if (parameter.ParameterType == eventType)
parameters.Add(@event);
else if (parameter.ParameterType == tokenType)
parameters.Add(ct);
else parameters.Add(null);
}
var result = container.Handler.Invoke(plugin, parameters.ToArray());
if (container.IsAwaitable)
await (Task)result!;
}
return @event;
}
}

View File

@@ -1,10 +1,16 @@
using HopFrame.Core;
using HopFrame.Core.Config;
using HopFrame.Core.Callbacks;
using HopFrame.Web.Components;
using HopFrame.Web.Components.Pages;
using HopFrame.Web.Plugins;
using HopFrame.Web.Plugins.Internal;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Web;
@@ -20,7 +26,7 @@ public static class ServiceCollectionExtensions {
/// <returns>The same service collection that is passed in</returns>
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null, bool addRazorComponents = true) {
var config = new HopFrameConfig();
configurator.Invoke(new HopFrameConfigurator(config));
configurator.Invoke(new HopFrameConfigurator(config, services));
return AddHopFrame(services, config, fluentUiLibraryConfiguration, addRazorComponents);
}
@@ -37,6 +43,10 @@ public static class ServiceCollectionExtensions {
services.AddHopFrameServices();
services.AddFluentUIComponents(fluentUiLibraryConfiguration);
services.AddScoped<IPluginOrchestrator, PluginOrchestrator>();
services.AddScoped<IFileService, FileService>();
services.TryAddScoped<ISearchSuggestionProvider, SearchSuggestionProvider>();
if (addRazorComponents) {
services.AddRazorComponents()
.AddInteractiveServerComponents();

View File

@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Components.Forms;
namespace HopFrame.Web.Services;
/// <summary>
/// Provides file handling capabilities for downloading and uploading files.
/// </summary>
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);
/// <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();
}

View File

@@ -0,0 +1,11 @@
using HopFrame.Core.Config;
namespace HopFrame.Web.Services;
public interface ISearchSuggestionProvider {
public IEnumerable<string> GenerateSearchSuggestions(TableConfig table, string searchText);
public string CompleteSearchSuggestion(TableConfig table, string searchText, string selectedSuggestion);
}

View File

@@ -0,0 +1,31 @@
using HopFrame.Web.Components.Pages;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
namespace HopFrame.Web.Services.Implementation;
internal sealed class FileService(IJSRuntime runtime) : IFileService {
public async Task DownloadFile(string name, byte[] data) {
using var stream = new DotNetStreamReference(new MemoryStream(data));
await runtime.InvokeVoidAsync("downloadFileFromStream", name, stream);
}
public Task<IBrowserFile> UploadFile() {
var result = new TaskCompletionSource<IBrowserFile>();
if (HopFrameTablePage.CurrentInstance is null)
result.SetException(new InvalidOperationException("No table page visible"));
HopFrameTablePage.CurrentInstance!.OnFileUpload = files => {
result.SetResult(files.First());
HopFrameTablePage.CurrentInstance.OnFileUpload = null;
return Task.CompletedTask;
};
runtime.InvokeVoidAsync("triggerClick", HopFrameTablePage.CurrentInstance.FileInputElement!.Element);
return result.Task;
}
}

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

@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,7 @@
@page "/counter"
@using HopFrame.Web.Components.Layout
@rendermode InteractiveServer
@layout HopFrameLayout
<PageTitle>Counter</PageTitle>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -9,9 +9,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.13.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.13.2" />
</ItemGroup>
<ItemGroup>

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 virtual List<Post> Posts { get; set; } = new();
public override string ToString() {
return Username;
}
}

View File

@@ -1,11 +1,10 @@
using System.Collections;
using HopFrame.Testing;
using Microsoft.FluentUI.AspNetCore.Components;
using HopFrame.Testing.Components;
using HopFrame.Testing.Models;
using HopFrame.Web;
using HopFrame.Web.Components.Pages;
using Microsoft.EntityFrameworkCore;
using Message = HopFrame.Testing.Models.Message;
var builder = WebApplication.CreateBuilder(args);
@@ -36,6 +35,11 @@ builder.Services.AddHopFrame(options => {
.SetOrderIndex(3);
table.AddVirtualProperty("Name", (user, _) => $"{user.FirstName} {user.LastName}")
/*.SetVirtualParser((model, input, _) => {
var split = input.Split(' ');
model.FirstName = split.FirstOrDefault();
model.LastName = split.LastOrDefault();
})*/
.SetOrderIndex(2);
table.SetDisplayName("Benutzer");
@@ -47,9 +51,14 @@ builder.Services.AddHopFrame(options => {
.FormatEach<Post>((post, _) => post.Caption);
});
context.Table<Post>()
.ShowSearchSuggestions(false);
context.Table<Post>()
.Property(p => p.Author)
.Format((user, _) => $"{user.FirstName} {user.LastName}");
//.Format((user, _) => $"{user.FirstName} {user.LastName}")
.SetDisplayedProperty(u => u.Username)
.SetValidator((_, _) => []);
context.Table<Post>()
.Property(p => p.Id)
@@ -74,11 +83,41 @@ builder.Services.AddHopFrame(options => {
return errors;
})*/;
context.Table<Post>()
.SetOrderIndex(-1);
/*context.Table<Post>()
.SetOrderIndex(-1)
.Ignore(true);*/
});
options.AddCustomView("Counter", "/counter")
.SetDescription("A custom view")
.SetPolicy("counter.view");
options.AddExporters();
options.AddCustomRepository<GuestRepository, Guest, int>(g => g.Id, table => {
table.SetDisplayName("Guests");
table.Property(g => g.Messages)
.ForceRelation(true)
.FormatEach<Message>((m, _) => m.Content);
});
options.AddCustomRepository<MessageRepository, Message, int>(m => m.MessageIdentifier, table => {
table.SetDisplayName("Messages");
table.Property(m => m.Receiver)
.ForceRelation()
.Format((u, _) => u.Name);
table.Property(m => m.Sender)
.ForceRelation()
.Format((u, _) => u.Username ?? string.Empty);
});
});
builder.Services.AddSingleton<MessageRepository>();
builder.Services.AddSingleton<GuestRepository>();
var app = builder.Build();
// Configure the HTTP request pipeline.

View File

@@ -8,7 +8,7 @@ public class DbContextConfiguratorTests {
[Fact]
public void Table_WithConfigurator_InvokesConfigurator() {
// Arrange
var dbContextConfig = new DbContextConfig(typeof(MockDbContext));
var dbContextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var configurator = new DbContextConfigurator<MockDbContext>(dbContextConfig);
var mockConfigurator = new Mock<Action<TableConfigurator<MockModel>>>();
@@ -22,7 +22,7 @@ public class DbContextConfiguratorTests {
[Fact]
public void Table_ReturnsCorrectTableConfigurator() {
// Arrange
var dbContextConfig = new DbContextConfig(typeof(MockDbContext));
var dbContextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var configurator = new DbContextConfigurator<MockDbContext>(dbContextConfig);
// Act

View File

@@ -9,7 +9,7 @@ public class PropertyConfiguratorTests {
public void SetDisplayName_SetsNameProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
var displayName = "ID";
@@ -24,7 +24,7 @@ public class PropertyConfiguratorTests {
public void List_SetsListAndSearchableProperties() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -39,7 +39,7 @@ public class PropertyConfiguratorTests {
public void IsSortable_SetsSortableProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -53,7 +53,7 @@ public class PropertyConfiguratorTests {
public void IsSearchable_SetsSearchableProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -67,7 +67,7 @@ public class PropertyConfiguratorTests {
public void SetDisplayedProperty_SetsDisplayedProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<MockModel>(propertyConfig);
Expression<Func<MockModel, int>> propertyExpression = model => model.Id;
@@ -83,7 +83,7 @@ public class PropertyConfiguratorTests {
public void Format_SetsFormatter() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
Func<int, IServiceProvider, string> formatter = (val, _) => val.ToString();
@@ -98,7 +98,7 @@ public class PropertyConfiguratorTests {
public void SetParser_SetsParser() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
Func<string, IServiceProvider, int> parser = (str, _) => int.Parse(str);
@@ -113,7 +113,7 @@ public class PropertyConfiguratorTests {
public void SetEditable_SetsEditableProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -127,7 +127,7 @@ public class PropertyConfiguratorTests {
public void SetCreatable_SetsCreatableProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -141,7 +141,7 @@ public class PropertyConfiguratorTests {
public void DisplayValue_SetsDisplayValueProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -155,7 +155,7 @@ public class PropertyConfiguratorTests {
public void IsTextArea_SetsTextAreaProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
// Act
@@ -169,7 +169,7 @@ public class PropertyConfiguratorTests {
public void SetTextAreaRows_SetsTextAreaRowsProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
var rows = 10;
@@ -184,7 +184,7 @@ public class PropertyConfiguratorTests {
public void SetValidator_SetsValidator() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
Func<int, IServiceProvider, IEnumerable<string>> validator = (_, _) => new List<string>();
@@ -199,7 +199,7 @@ public class PropertyConfiguratorTests {
public void SetOrderIndex_SetsOrderProperty() {
// Arrange
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!,
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0), 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0), 0);
var configurator = new PropertyConfigurator<int>(propertyConfig);
var orderIndex = 1;
@@ -213,7 +213,7 @@ public class PropertyConfiguratorTests {
[Fact]
public void Constructor_SetsTableProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
// Act
var propertyConfig = new PropertyConfig(typeof(MockModel).GetProperty("Id")!, tableConfig, 0);

View File

@@ -9,7 +9,7 @@ public class TableConfiguratorTests {
public void Ignore_SetsIgnoredProperty() {
// Arrange
var tableConfig =
new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
// Act
@@ -22,7 +22,7 @@ public class TableConfiguratorTests {
[Fact]
public void Property_ReturnsCorrectPropertyConfigurator() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
Expression<Func<MockModel, int>> propertyExpression = model => model.Id;
@@ -35,7 +35,7 @@ public class TableConfiguratorTests {
public void Property_WithConfigurator_ReturnsCorrectPropertyConfigurator() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
Expression<Func<MockModel, int>> propertyExpression = model => model.Id;
@@ -52,7 +52,7 @@ public class TableConfiguratorTests {
[Fact]
public void AddVirtualProperty_AddsVirtualPropertyToConfig() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
Func<MockModel, IServiceProvider, string> template = (model, _) => model.Name!;
@@ -63,14 +63,14 @@ public class TableConfiguratorTests {
var virtualProperty = tableConfig.Properties.SingleOrDefault(p => p.Name == "VirtualName");
Assert.NotNull(virtualProperty);
Assert.NotNull(propertyConfigurator);
Assert.True(virtualProperty.IsListingProperty);
Assert.True(virtualProperty.IsVirtualProperty);
Assert.Equal("VirtualName", virtualProperty.Name);
}
[Fact]
public void AddVirtualProperty_WithConfigurator_AddsVirtualPropertyToConfig() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
Func<MockModel, IServiceProvider, string> template = (model, _) => model.Name!;
@@ -84,14 +84,14 @@ public class TableConfiguratorTests {
var virtualProperty = tableConfig.Properties.SingleOrDefault(p => p.Name == "VirtualName");
Assert.NotNull(virtualProperty);
Assert.NotNull(propertyConfigurator);
Assert.True(virtualProperty.IsListingProperty);
Assert.True(virtualProperty.IsVirtualProperty);
Assert.Equal("VirtualName", virtualProperty.Name);
}
[Fact]
public void SetDisplayName_SetsDisplayNameProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var displayName = "Mock Model Display Name";
@@ -105,7 +105,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetDescription_SetsDescriptionProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var description = "Mock Model Description";
@@ -119,7 +119,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetOrderIndex_SetsOrderIndexProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var orderIndex = 1;
@@ -133,7 +133,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetViewPolicy_SetsViewPolicyProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var policy = "ViewPolicy";
@@ -147,7 +147,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetUpdatePolicy_SetsUpdatePolicyProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var policy = "UpdatePolicy";
@@ -161,7 +161,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetCreatePolicy_SetsCreatePolicyProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var policy = "CreatePolicy";
@@ -175,7 +175,7 @@ public class TableConfiguratorTests {
[Fact]
public void SetDeletePolicy_SetsDeletePolicyProperty() {
// Arrange
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "MockModels", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "MockModels", 0);
var configurator = new TableConfigurator<MockModel>(tableConfig);
var policy = "DeletePolicy";
@@ -189,7 +189,7 @@ public class TableConfiguratorTests {
[Fact]
public void Constructor_WithKeyProperty_DisablesEdit() {
// Act
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel2), "Models2", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel2), "Models2", 0);
var prop = tableConfig.Properties.SingleOrDefault(prop => prop.Info.Name == nameof(MockModel2.Id));
// Assert
@@ -200,7 +200,7 @@ public class TableConfiguratorTests {
[Fact]
public void Constructor_WithGeneratedProperty_DisablesEditAndCreate() {
// Act
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel2), "Models2", 0);
var tableConfig = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel2), "Models2", 0);
var prop = tableConfig.Properties.SingleOrDefault(prop => prop.Info.Name == nameof(MockModel2.Number));
// Assert

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2"/>

View File

@@ -1,6 +1,8 @@
using HopFrame.Core.Config;
using HopFrame.Core.Services;
using HopFrame.Core.Services.Implementations;
using HopFrame.Tests.Core.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
@@ -11,7 +13,7 @@ public class ContextExplorerTests {
public void GetTables_ReturnsNonIgnoredTables() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig1 = contextConfig.Tables[0];
var tableConfig2 = contextConfig.Tables[1];
config.Contexts.Add(contextConfig);
@@ -33,7 +35,7 @@ public class ContextExplorerTests {
public void GetTable_ByDisplayName_ReturnsCorrectTable() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
config.Contexts.Add(contextConfig);
tableConfig.DisplayName = "TestTable";
@@ -54,7 +56,7 @@ public class ContextExplorerTests {
public void GetTable_ByDisplayName_ReturnsNullIfTableNotFound() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
config.Contexts.Add(contextConfig);
tableConfig.DisplayName = "TestTable";
@@ -74,7 +76,7 @@ public class ContextExplorerTests {
public void GetTable_ByType_ReturnsCorrectTable() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
config.Contexts.Add(contextConfig);
@@ -94,7 +96,7 @@ public class ContextExplorerTests {
public void GetTable_ByType_ReturnsNullIfTableNotFound() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
config.Contexts.Add(contextConfig);
@@ -113,7 +115,7 @@ public class ContextExplorerTests {
public void GetTableManager_ReturnsCorrectTableManager() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = new TableConfig(contextConfig, typeof(MockModel), "Models", 0);
contextConfig.Tables.Add(tableConfig);
config.Contexts.Add(contextConfig);
@@ -121,6 +123,7 @@ public class ContextExplorerTests {
var dbContext = new MockDbContext();
var provider = new Mock<IServiceProvider>();
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()));
// Act
@@ -135,7 +138,7 @@ public class ContextExplorerTests {
public void GetTableManager_ReturnsNullIfDbContextNotFound() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = new TableConfig(contextConfig, typeof(MockModel), "MockModels", 0);
contextConfig.Tables.Add(tableConfig);
config.Contexts.Add(contextConfig);
@@ -154,7 +157,7 @@ public class ContextExplorerTests {
public void GetTableManager_ReturnsNullIfTableNotFound() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = new TableConfig(contextConfig, typeof(MockModel), "Models", 0);
contextConfig.Tables.Add(tableConfig);
config.Contexts.Add(contextConfig);
@@ -175,7 +178,7 @@ public class ContextExplorerTests {
public void SeedTableData_SetsTableSeededFlag() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
config.Contexts.Add(contextConfig);
@@ -194,7 +197,7 @@ public class ContextExplorerTests {
public void SeedTableData_SetsTablePropertiesCorrectly() {
// Arrange
var config = new HopFrameConfig();
var contextConfig = new DbContextConfig(typeof(MockDbContext));
var contextConfig = new DbContextConfig(typeof(MockDbContext), null!);
var tableConfig = contextConfig.Tables[0];
var tableConfig2 = contextConfig.Tables[1];
config.Contexts.Add(contextConfig);

View File

@@ -17,9 +17,9 @@ public class DisplayPropertyTests {
var contextMock = new Mock<DbContext>();
_providerMock = new Mock<IServiceProvider>();
_explorerMock = new Mock<IContextExplorer>();
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
_config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
_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]
@@ -39,7 +39,7 @@ public class DisplayPropertyTests {
// Arrange
var item = "test";
var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0) {
IsListingProperty = true,
IsVirtualProperty = true,
Formatter = (obj, provider) => Task.FromResult(((string)obj).ToUpper())
};
@@ -106,7 +106,7 @@ public class DisplayPropertyTests {
_explorerMock
.Setup(e => e.GetTable(item.Inner.GetType()))
.Returns(new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0) {
.Returns(new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0) {
Properties = { innerPropConfig }
});

View File

@@ -10,6 +10,9 @@ using Moq;
namespace HopFrame.Tests.Core.Services;
public class TableManagerTests {
private Mock<ISearchExpressionBuilder> _searchBuilderMock = new();
private Mock<DbContext> CreateMockDbContext<TModel>(List<TModel> data) where TModel : class {
var dbContext = new Mock<DbContext>();
var dbSet = CreateMockDbSet(data);
@@ -40,7 +43,7 @@ public class TableManagerTests {
}
[Fact]
public void LoadPage_ReturnsPagedData() {
public async Task LoadPage_ReturnsPagedData() {
// Arrange
var data = new List<MockModel> {
new MockModel { Id = 1, Name = "Item1" },
@@ -48,45 +51,19 @@ public class TableManagerTests {
new MockModel { Id = 3, Name = "Item3" }
};
var dbContext = CreateMockDbContext(data);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>();
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
var result = manager.LoadPage(1, 2).ToList();
var result = (await manager.LoadPage(1, 2)).ToArray();
// Assert
Assert.Single(result);
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)), 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]
public async Task TotalPages_ReturnsCorrectPageCount() {
// Arrange
@@ -96,10 +73,10 @@ public class TableManagerTests {
new MockModel { Id = 3, Name = "Item3" }
};
var dbContext = new MockDbContext();
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>();
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.SaveChangesAsync();
@@ -118,10 +95,10 @@ public class TableManagerTests {
new MockModel { Id = 2, Name = "Item2" }
};
var dbContext = CreateMockDbContext(data);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>();
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();
// Act
@@ -139,10 +116,10 @@ public class TableManagerTests {
new MockModel { Id = 1, Name = "Item1" }
};
var dbContext = CreateMockDbContext(data);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>();
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
await manager.EditItem(data.First());
@@ -156,10 +133,10 @@ public class TableManagerTests {
// Arrange
var data = new List<MockModel>();
var dbContext = CreateMockDbContext(data);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext)), typeof(MockModel), "Models", 0);
var config = new TableConfig(new DbContextConfig(typeof(MockDbContext), null!), typeof(MockModel), "Models", 0);
var explorer = new Mock<IContextExplorer>();
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" };
// Act

View File

@@ -22,7 +22,7 @@ public class HopFrameEditorTests : TestContext {
var dialogServiceMock = new Mock<IDialogService>();
var toastServiceMock = new Mock<IToastService>();
var serviceProviderMock = new Mock<IServiceProvider>();
var contextConfig = new DbContextConfig(typeof(MyDbContext));
var contextConfig = new DbContextConfig(typeof(MyDbContext), null!);
var tableConfig = new TableConfig(contextConfig, typeof(MyTable), "Table1", 0) {
DisplayName = "Table1",
ViewPolicy = "Policy1"
@@ -47,7 +47,7 @@ public class HopFrameEditorTests : TestContext {
var dialogData = new EditorDialogData(tableConfig, new MyTable());
var dialog = new FluentDialog() {
Instance = new DialogInstance(typeof(HopFrameEditor), new DialogParameters(), dialogData)
Instance = new DialogInstance(typeof(HopFrameEditor), new DialogParameters(), dialogData, null)
};
// Act

View File

@@ -17,7 +17,7 @@ public class HopFrameSideMenuTests : TestContext {
// Arrange
var contextExplorerMock = new Mock<IContextExplorer>();
var authHandlerMock = new Mock<IHopFrameAuthHandler>();
var contextConfig = new DbContextConfig(typeof(MyDbContext));
var contextConfig = new DbContextConfig(typeof(MyDbContext), null!);
var tableConfigs = new List<TableConfig> {
new (contextConfig, typeof(MyTable), "Table1", 0),
new (contextConfig, typeof(MyTable2), "Table2", 1)

View File

@@ -16,7 +16,7 @@ public class HopFrameHomeTests : TestContext {
// Arrange
var contextExplorerMock = new Mock<IContextExplorer>();
var authHandlerMock = new Mock<IHopFrameAuthHandler>();
var contextConfig = new DbContextConfig(typeof(MyDbContext));
var contextConfig = new DbContextConfig(typeof(MyDbContext), null!);
var tableConfigs = new List<TableConfig> {
new TableConfig(contextConfig, typeof(MyTable), "Table1", 0) {
DisplayName = "Table1",

View File

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

Some files were not shown because too many files have changed in this diff Show More