57 Commits

Author SHA1 Message Date
d393ae787d Code cleanup + new pipeline setup 2026-01-18 17:42:37 +01:00
10913b0a21 Merge branch 'feature/test-reports' into 'dev'
Feature/test reports

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

Closes #27

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

Closes #32

See merge request leon.hoppe/hopframe!34
2025-03-15 11:33:05 +00:00
5c6fafcd6f Added documentation for custom repos and exporter plugin 2025-03-15 12:34:16 +01:00
222d4276d2 Added support for custom repositories 2025-03-14 21:46:41 +01:00
4407d173a9 Reverted pipeline to include all jobs 2025-02-28 12:45:35 +01:00
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
966ced57d6 Added missing installation instructions 2025-01-31 16:28:32 +01:00
ec3ab67cb9 Merge branch 'fix/selection' into 'dev'
Resolve "List relation selection bug"

Closes #13

See merge request leon.hoppe/hopframe!27
2025-01-31 15:23:15 +00:00
d802fde7d8 Removed select all button 2025-01-31 16:24:25 +01:00
88d843c1cb Merge branch 'fix/cancellabe-relations' into 'dev'
Resolve "Relation edit and cancel not supported"

Closes #16

See merge request leon.hoppe/hopframe!26
2025-01-28 17:09:56 +00:00
fecbc0717b Implemented deferred entry manipulation 2025-01-28 18:10:56 +01:00
5a342e2c53 Implemented primitive change reversion 2025-01-28 16:45:21 +01:00
e553d47841 Merge branch 'fix/row-buttons' into 'dev'
Resolve "Edit button for wrong row"

Closes #20

See merge request leon.hoppe/hopframe!25
2025-01-28 11:16:07 +00:00
d09264d700 Fixed wrong element selection for action buttons 2025-01-28 12:17:06 +01:00
9e931c77e0 Merge branch 'fix/relations' into 'dev'
Resolve "Support n-m relations"

Closes #18

See merge request leon.hoppe/hopframe!24
2025-01-28 11:08:30 +00:00
c8a342986b Added n-m relation mapping 2025-01-28 12:09:16 +01:00
62e4daf60d Merge branch 'feature/max-length' into 'dev'
Resolve "Max display length for listing"

Closes #21

See merge request leon.hoppe/hopframe!23
2025-01-28 09:15:59 +00:00
ac320d7445 Fixed test for table view 2025-01-28 10:17:00 +01:00
193f334708 Added maximum display length 2025-01-28 10:11:35 +01:00
b288d58c5d Merge branch 'feature/async-delegates' into 'dev'
Resolve "Support async for all delegates"

Closes #19

See merge request leon.hoppe/hopframe!22
2025-01-27 16:57:43 +00:00
b6a7c508db Implemented async delegates 2025-01-27 17:58:40 +01:00
d42f024175 Merge branch 'feature/api-abstraction' into 'dev'
Resolve "Web API abstraction"

Closes #22

See merge request leon.hoppe/hopframe!21
2025-01-27 16:35:20 +00:00
2f15986dbf Added a simple web api abstraction method 2025-01-27 17:36:20 +01:00
6842e48a70 Merge branch 'fix/missing-styles' into 'dev'
Resolve "Missing styles"

Closes #17

See merge request leon.hoppe/hopframe!20
2025-01-27 16:06:52 +00:00
fd71767271 Added missing files 2025-01-27 17:07:49 +01:00
107 changed files with 4559 additions and 522 deletions

View File

@@ -4,13 +4,12 @@ stages:
- build - build
- test - test
- publish - publish
- publish-help
before_script:
- echo "Setting up environment"
- 'dotnet --version'
build: build:
stage: build stage: build
only:
- pushes
script: script:
- dotnet restore - dotnet restore
- dotnet build --configuration Release --no-restore - dotnet build --configuration Release --no-restore
@@ -21,10 +20,31 @@ build:
test: test:
stage: test stage: test
only:
- pushes
script: script:
- dotnet test --verbosity normal - dotnet test HopFrame.sln --logger "trx;LogFilePrefix=testresults" --collect:"XPlat Code Coverage" --results-directory TestResults
dependencies: - dotnet tool install --global trx2junit
- build - 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: publish:
stage: publish stage: publish
@@ -37,3 +57,19 @@ publish:
dependencies: dependencies:
- build - build
- test - test
publish-help:
stage: publish-help
tags:
- 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"> <project version="4">
<component name="RiderProjectSettingsUpdater"> <component name="RiderProjectSettingsUpdater">
<option name="singleClickDiffPreview" value="1" /> <option name="singleClickDiffPreview" value="1" />
<option name="unhandledExceptionsIgnoreList" value="1" />
<option name="vcsConfiguration" value="3" /> <option name="vcsConfiguration" value="3" />
</component> </component>
</project> </project>

View File

@@ -3,17 +3,19 @@
<component name="AutoGeneratedRunConfigurationManager"> <component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="http">HopFrame.Testing/HopFrame.Testing.csproj</projectFile> <projectFile profileName="http">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
<projectFile profileName="https">HopFrame.Testing/HopFrame.Testing.csproj</projectFile> <projectFile profileName="https">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
<projectFile profileName="http">testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj</projectFile>
<projectFile profileName="https">testing/HopFrame.Testing.Api/HopFrame.Testing.Api.csproj</projectFile>
<projectFile profileName="http">testing/HopFrame.Testing/HopFrame.Testing.csproj</projectFile> <projectFile profileName="http">testing/HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
</component> </component>
<component name="AutoImportSettings"> <component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="prepared project for release"> <list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.gitlab-ci.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.gitlab-ci.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Core/HopFrame.Core.csproj" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Core/HopFrame.Core.csproj" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/HopFrame.Web.csproj" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/HopFrame.Web.csproj" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
<change beforePath="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" beforeDir="false" afterPath="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -37,6 +39,7 @@
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="RESET_MODE" value="HARD" />
</component> </component>
<component name="GitLabMergeRequestFiltersHistory">{ <component name="GitLabMergeRequestFiltersHistory">{
&quot;lastFilter&quot;: { &quot;lastFilter&quot;: {
@@ -51,67 +54,111 @@
<component name="GitLabMergeRequestsSettings">{ <component name="GitLabMergeRequestsSettings">{
&quot;selectedUrlAndAccountId&quot;: { &quot;selectedUrlAndAccountId&quot;: {
&quot;first&quot;: &quot;https://git.leon-hoppe.de/leon.hoppe/hopframe.git&quot;, &quot;first&quot;: &quot;https://git.leon-hoppe.de/leon.hoppe/hopframe.git&quot;,
&quot;second&quot;: &quot;2d65fdcb-5f13-45ad-a7ba-91dd4a88d6e4&quot; &quot;second&quot;: &quot;f58c9371-9f54-454e-a0db-5b4bc1187bad&quot;
} }
}</component> }</component>
<component name="HighlightingSettingsPerFile"> <component name="HighlightingSettingsPerFile">
<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/62/1fb63ed0/IDisposable.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/a0/0a968c53/IEnumerable`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/ad/ba9a50e7/ICollection.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/DecompilerCache/decompiler/c73b3c6c598640c592fd3c6fa226c286e90908/fc/6f7933d2/ICollection`1.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/0f73968de5cdfe0aa57817b8dd2a3c5d1db615ba4ae4629a5af59bb6c8922/RemoteNavigationManager.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/10c66a9a1e137111895f7182a2ae246eabe06a261578c3fa495a45f6f177d35/IconVariant.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669/List.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/24dd1164ba47541cb1d3eb011e638e16953dbea3ae3f4dc208c3bbf3e96298a/ServiceCollectionServiceExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<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/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/2751d5afefca5424bfc4b21347f581372f7a739c0ae4df661ea557fcb97ef20/EnumExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/2a4d2ce4c06ab596b3676c5cf06066b4391ec7dd93cdf8f0334b69dc1a9de/TextReader.cs" root0="FORCE_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/4c41a7d338749915157d56585365d1693fbad6be8231d3d583b1cf10d16896d9/FluentIcon.razor.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/4ee221fd7e91e9a4c14ff82aae2ee938edecde35a934133e991aba56aa9499/Icon.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92/CancellationTokenSource.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/60e7b22380df80ef6fefe43138047f49ec6eff4b25c12b42ce3d6ed5aac/MethodInvokerCommon.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/6354a7b35d7821629924d3676acd7e67a6f7f94343e0e66ec439aa2bd6ed5/ThrowHelper.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/6d1d64f05e7045295fa180276a8c2aef0302c9e96eb53b3431ab13db4579/FluentAppBarItem.razor.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/6fe785cceb29ca2d1da78e157315815a7c4372b582a20a71c28b210f9d56e/IconsExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7ad7d2d0ae865063993eb8a03427815ea3bdb6a774e0a2f95512e9f669a4f489/MemberEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/876cd892fc66a9dc8f6afd3704c264acebdfc46aed08089463e8117c21a532/String.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/87c584767b46b5fd42769be76547105558e6690f785614efddca134b2d682/Type.cs" root0="FORCE_HIGHLIGHTING" /> <setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/87c584767b46b5fd42769be76547105558e6690f785614efddca134b2d682/Type.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/8d5d6cbff46ddc7b152381f92ae1ae51d3e7b57b14dd23840a11f5aaaaed396/InternalEntityEntry.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a882d183338544fdbcbdfc7b6d3dcb78916630765551644a221b5be9c45a121b/Int32.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/aa3ea54f92373c58ec1149fbd41215869a98bd385c30584bc6db2fa3c6e88443/Filled24.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/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/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/bfff78ecaa39c818519fc918bb2d4bbdca6ad93d7170f5cf325f67ccd0b97d43/BooleanAsserts.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d0165cb640e16fb3b8fe6932c042fc2917cd7f2770ff123cf7b9d11b5bfc6/Task.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/d1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126/Stream.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/d858ddb35a8e36df5573b7612542f9ad50f426b8ab43818587d1ac65fab14829/DatabaseGeneratedAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/dac3553c90d47a746e7e7f02faecb1a5e581090/Components_AppBar_FluentAppBarItem_razor.g.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/e26a4f2df232f16e374b9719f883c1b2419f6341838d94b7581db9c7d2de17/IconInfo.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/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/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/fd57398b7dc3a8ce7da2786f2c67289c3d974658a9e90d0c1e84db3d965fbf1/Console.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ff37d54b3bf4d2756237fb789635831532603376e940f63d634b869d26d74c/Regular16.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="file://$PROJECT_DIR$/src/HopFrame.Core/Config/DbContextConfig.cs" root0="FORCE_HIGHLIGHTING" />
</component> </component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" /> <component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProjectColorInfo">{ <component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3 &quot;associatedIndex&quot;: 3
}</component> }</component>
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" /> <component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true"> <component name="ProjectLevelVcsManager">
<OptionsSetting value="false" id="Update" />
<ConfirmationsSetting value="2" id="Add" /> <ConfirmationsSetting value="2" id="Add" />
</component> </component>
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run", &quot;.NET Launch Settings Profile.HopFrame.Testing.Api: https.executor&quot;: &quot;Run&quot;,
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run", &quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
".NET Project.HopFrame.Testing.executor": "Run", &quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;,
"72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor": "Debug", &quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;72b118b0-a6fc-4561-acdf-74f0b454dbb8.executor&quot;: &quot;Debug&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"git-widget-placeholder": "release/v3.0.0", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"list.type.of.created.stylesheet": "CSS", &quot;b5f11219-dfc4-47a1-b02c-90ab603034fb.executor&quot;: &quot;Debug&quot;,
"node.js.detected.package.eslint": "true", &quot;dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor&quot;: &quot;Debug&quot;,
"node.js.detected.package.tslint": "true", &quot;git-widget-placeholder&quot;: &quot;dev&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"nodejs_package_manager_path": "npm", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"settings.editor.selected.configurable": "preferences.environmentSetup", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"vue.rearranger.settings.migration": "true" &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"> <component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile"> <configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" /> <option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" /> <option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
@@ -120,18 +167,53 @@
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" /> <option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" /> <option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" /> <option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" /> <option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" /> <option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" /> <option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" /> <option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="HopFrame.Testing.Api: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<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_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.Api: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<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_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"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>
</configuration> </configuration>
<list> <list>
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing.Api: http" />
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing.Api: https" />
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: https" /> <item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: https" />
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: http" /> <item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: http" />
</list> </list>
@@ -159,127 +241,45 @@
<workItem from="1737199714142" duration="8344000" /> <workItem from="1737199714142" duration="8344000" />
<workItem from="1737208313207" duration="4612000" /> <workItem from="1737208313207" duration="4612000" />
<workItem from="1737281957060" duration="3232000" /> <workItem from="1737281957060" duration="3232000" />
<workItem from="1737293153907" duration="7750000" /> <workItem from="1737293153907" duration="8953000" />
</task> <workItem from="1737390240714" duration="60000" />
<task id="LOCAL-00001" summary="Added basic configuration"> <workItem from="1737390360987" duration="601000" />
<option name="closed" value="true" /> <workItem from="1737993570961" duration="4163000" />
<created>1736850899254</created> <workItem from="1738054766160" duration="7449000" />
<option name="number" value="00001" /> <workItem from="1738075629332" duration="8862000" />
<option name="presentableId" value="LOCAL-00001" /> <workItem from="1738335286481" duration="2039000" />
<option name="project" value="LOCAL" /> <workItem from="1738403493974" duration="4231000" />
<updated>1736850899254</updated> <workItem from="1738418482606" duration="2795000" />
</task> <workItem from="1738421294144" duration="1651000" />
<task id="LOCAL-00002" summary="Added admin page navigation"> <workItem from="1738422949337" duration="141000" />
<option name="closed" value="true" /> <workItem from="1738512801911" duration="6776000" />
<created>1736855209077</created> <workItem from="1738769458367" duration="5256000" />
<option name="number" value="00002" /> <workItem from="1738774834563" duration="728000" />
<option name="presentableId" value="LOCAL-00002" /> <workItem from="1739301922710" duration="33000" />
<option name="project" value="LOCAL" /> <workItem from="1739352479748" duration="3047000" />
<updated>1736855209077</updated> <workItem from="1739369355001" duration="1751000" />
</task> <workItem from="1739461452173" duration="5533000" />
<task id="LOCAL-00003" summary="Added database loading logic"> <workItem from="1739550750776" duration="3613000" />
<option name="closed" value="true" /> <workItem from="1739617785048" duration="5992000" />
<created>1736859917232</created> <workItem from="1739975843065" duration="1921000" />
<option name="number" value="00003" /> <workItem from="1740168829540" duration="1382000" />
<option name="presentableId" value="LOCAL-00003" /> <workItem from="1740595969750" duration="34000" />
<option name="project" value="LOCAL" /> <workItem from="1740736919561" duration="191000" />
<updated>1736859917232</updated> <workItem from="1740738257628" duration="3216000" />
</task> <workItem from="1740741585276" duration="17000" />
<task id="LOCAL-00004" summary="Started working on listing page"> <workItem from="1740742098571" duration="78000" />
<option name="closed" value="true" /> <workItem from="1740742471317" duration="672000" />
<created>1736885531216</created> <workItem from="1741974241977" duration="10854000" />
<option name="number" value="00004" /> <workItem from="1742038098473" duration="990000" />
<option name="presentableId" value="LOCAL-00004" /> <workItem from="1742059898156" duration="3488000" />
<option name="project" value="LOCAL" /> <workItem from="1744725284649" duration="60000" />
<updated>1736885531216</updated> <workItem from="1744916016381" duration="66000" />
</task> <workItem from="1744916106166" duration="49000" />
<task id="LOCAL-00005" summary="Added entry saving support"> <workItem from="1744966207145" duration="5231000" />
<option name="closed" value="true" /> <workItem from="1751713720880" duration="8243000" />
<created>1736970238802</created> <workItem from="1751741813788" duration="4623000" />
<option name="number" value="00005" /> <workItem from="1768753475773" duration="455000" />
<option name="presentableId" value="LOCAL-00005" /> <workItem from="1768753946559" duration="344000" />
<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>
<task id="LOCAL-00016" summary="Added documentation for the configurators and service extensions methods"> <task id="LOCAL-00016" summary="Added documentation for the configurators and service extensions methods">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -329,7 +329,351 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1737300408069</updated> <updated>1737300408069</updated>
</task> </task>
<option name="localTasksCounter" value="22" /> <task id="LOCAL-00022" summary="Included readme file in projects">
<option name="closed" value="true" />
<created>1737301230493</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1737301230493</updated>
</task>
<task id="LOCAL-00023" summary="Added missing files">
<option name="closed" value="true" />
<created>1737994074137</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1737994074137</updated>
</task>
<task id="LOCAL-00024" summary="Added a simple web api abstraction method">
<option name="closed" value="true" />
<created>1737995782424</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1737995782424</updated>
</task>
<task id="LOCAL-00025" summary="Implemented async delegates">
<option name="closed" value="true" />
<created>1737997122807</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1737997122807</updated>
</task>
<task id="LOCAL-00026" summary="Added maximum display length">
<option name="closed" value="true" />
<created>1738055497527</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1738055497527</updated>
</task>
<task id="LOCAL-00027" summary="Fixed test for table view">
<option name="closed" value="true" />
<created>1738055822074</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1738055822074</updated>
</task>
<task id="LOCAL-00028" summary="Added n-m relation mapping">
<option name="closed" value="true" />
<created>1738062559567</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1738062559567</updated>
</task>
<task id="LOCAL-00029" summary="Fixed wrong element selection for action buttons">
<option name="closed" value="true" />
<created>1738063028173</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1738063028173</updated>
</task>
<task id="LOCAL-00030" summary="Implemented primitive change reversion">
<option name="closed" value="true" />
<created>1738079122848</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1738079122848</updated>
</task>
<task id="LOCAL-00031" summary="Implemented deferred entry manipulation">
<option name="closed" value="true" />
<created>1738084259089</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1738084259089</updated>
</task>
<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>
<option name="localTasksCounter" value="65" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -339,38 +683,17 @@
<option name="coveragePercentColumnWidth" value="129" /> <option name="coveragePercentColumnWidth" value="129" />
<option name="sortOrder" value="DESCENDING" /> <option name="sortOrder" value="DESCENDING" />
<option name="sortedColumn" value="1" /> <option name="sortedColumn" value="1" />
<option name="symbolColumnWidth" value="451" /> <option name="symbolColumnWidth" value="559" />
<coverage-tree-state> <coverage-tree-state>
<expand> <expand>
<path> <path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" /> <item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 32% 1123/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path> </path>
<path> <path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" /> <item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 32% 1123/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Total 53% 1150/2443" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 46% 398/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" /> <item name="Core 40% 862/1439" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 32% 1123/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 46% 398/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 94% 22/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 32% 1123/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 46% 398/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 94% 22/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 94% 22/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path>
<path>
<item name="rootNode" type="c53c71d1:RiderDotCoverCoverageTreeModel$RootNode" />
<item name="Total 32% 1123/1657" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Core 46% 398/743" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 94% 22/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="HopFrame.Core 94% 22/367" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
<item name="Config 93% 17/228" type="8b8fad3a:RiderDotCoverCoverageTreeNode" />
</path> </path>
</expand> </expand>
<select /> <select />
@@ -378,29 +701,44 @@
</component> </component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" /> <component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" /> <component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" /> <option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Added basic configuration" /> <MESSAGE value="Added default button removal feature" />
<MESSAGE value="Added admin page navigation" /> <MESSAGE value="Added custom search functionality" />
<MESSAGE value="Added database loading logic" /> <MESSAGE value="Added fully virtual properties" />
<MESSAGE value="Started working on listing page" /> <MESSAGE value="Added basic export and import feature" />
<MESSAGE value="Added entry saving support" /> <MESSAGE value="Finished converter plugin" />
<MESSAGE value="Added reload button and animation" /> <MESSAGE value="Patched CI" />
<MESSAGE value="Added relation picker dialog" /> <MESSAGE value="Prepared CI for v3.2.0" />
<MESSAGE value="Added automatic relation mapping" /> <MESSAGE value="Removed unused dependency" />
<MESSAGE value="Added property validation" /> <MESSAGE value="Fixed directory in pipeline" />
<MESSAGE value="Added creation/modification confirmation" /> <MESSAGE value="Reverted pipeline to include all jobs" />
<MESSAGE value="Removed Template" /> <MESSAGE value="Added support for custom repositories" />
<MESSAGE value="Added policy validation, ordering and virtual listing properties" /> <MESSAGE value="Added documentation for custom repos and exporter plugin" />
<MESSAGE value="Added n -&gt; m relation support" /> <MESSAGE value="Implemented sql search + negatable searches" />
<MESSAGE value="Added text area support and DI support for modifier functions" /> <MESSAGE value="Started working on search suggestions" />
<MESSAGE value="Addressed all build warnings" /> <MESSAGE value="Finished advanced search functionality" />
<MESSAGE value="Added documentation for the configurators and service extensions methods" /> <MESSAGE value="Made search suggestions togglable" />
<MESSAGE value="Created tests for the core module" /> <MESSAGE value="Updated test pipeline" />
<MESSAGE value="Added more tests" /> <MESSAGE value="Fixed typo in .gitlab-ci.yml" />
<MESSAGE value="Added web module tests" /> <MESSAGE value="updated test job" />
<MESSAGE value="Tested login functionality" /> <MESSAGE value="Combined coverage reports in test job" />
<MESSAGE value="prepared project for release" /> <MESSAGE value="combined test results" />
<option name="LAST_COMMIT_MESSAGE" value="prepared project for release" /> <MESSAGE value="added coverage to test job" />
<MESSAGE value="Updated coverage extraction" />
<MESSAGE value="fixed coverage percentage printing" />
<MESSAGE value="fixed echo cmd" />
<option name="LAST_COMMIT_MESSAGE" value="fixed echo cmd" />
</component> </component>
</project> </project>

View File

@@ -16,6 +16,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Core", "test
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Web", "tests\HopFrame.Tests.Web\HopFrame.Tests.Web.csproj", "{7AB4F4FF-E938-4A40-A7EB-7B2063262896}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Tests.Web", "tests\HopFrame.Tests.Web\HopFrame.Tests.Web.csproj", "{7AB4F4FF-E938-4A40-A7EB-7B2063262896}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Testing.Api", "testing\HopFrame.Testing.Api\HopFrame.Testing.Api.csproj", "{B13D2C4E-3993-47CD-A525-FD0B83980F0A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,7 @@ Global
{58490069-51DF-454C-8B54-7FB7D4BDFF81} = {9EB7FDBD-49C2-4872-9666-6F7AEBA541B2} {58490069-51DF-454C-8B54-7FB7D4BDFF81} = {9EB7FDBD-49C2-4872-9666-6F7AEBA541B2}
{2E2D29E0-53FA-462D-B4D2-4678CD106E29} = {141928CB-5977-4285-A986-5BD785F2883C} {2E2D29E0-53FA-462D-B4D2-4678CD106E29} = {141928CB-5977-4285-A986-5BD785F2883C}
{7AB4F4FF-E938-4A40-A7EB-7B2063262896} = {141928CB-5977-4285-A986-5BD785F2883C} {7AB4F4FF-E938-4A40-A7EB-7B2063262896} = {141928CB-5977-4285-A986-5BD785F2883C}
{B13D2C4E-3993-47CD-A525-FD0B83980F0A} = {9EB7FDBD-49C2-4872-9666-6F7AEBA541B2}
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -49,5 +52,9 @@ Global
{7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Debug|Any CPU.Build.0 = Debug|Any CPU {7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Release|Any CPU.Build.0 = Release|Any CPU {7AB4F4FF-E938-4A40-A7EB-7B2063262896}.Release|Any CPU.Build.0 = Release|Any CPU
{B13D2C4E-3993-47CD-A525-FD0B83980F0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B13D2C4E-3993-47CD-A525-FD0B83980F0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B13D2C4E-3993-47CD-A525-FD0B83980F0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B13D2C4E-3993-47CD-A525-FD0B83980F0A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

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. 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 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 ## Features
@@ -16,6 +16,14 @@ configure it to their needs to implement it fully in their data management pipel
## Getting Started ## Getting Started
### Installation
Install the nuget package using the CLI or the UI of your IDE:
```bash
dotnet add package HopFrame.Web
```
### Configuration ### Configuration
Configuring HopFrame is straightforward and flexible. You can easily define your contexts, tables, and their properties using the provided configurators. Configuring HopFrame is straightforward and flexible. You can easily define your contexts, tables, and their properties using the provided configurators.
@@ -72,6 +80,12 @@ builder.Services.AddHopFrame(options => {
}); });
``` ```
Then you need to map the frontend pages in your application:
```csharp
app.MapHopFrame();
```
### Usage ### Usage
- Navigate to `/admin` to access the admin dashboard and start managing your tables. - Navigate to `/admin` to access the admin dashboard and start managing your tables.

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; namespace HopFrame.Core.Config;
public class DbContextConfig { public interface ITableGroupConfig {
public Type ContextType { get; }
public List<TableConfig> Tables { get; }
public HopFrameConfig ParentConfig { get; }
}
public class DbContextConfig : ITableGroupConfig {
public Type ContextType { get; } public Type ContextType { get; }
public List<TableConfig> Tables { get; } = new(); public List<TableConfig> Tables { get; } = new();
public HopFrameConfig ParentConfig { get; }
public DbContextConfig(Type context) { public DbContextConfig(Type context, HopFrameConfig parentConfig) {
ContextType = context; ContextType = context;
ParentConfig = parentConfig;
foreach (var property in ContextType.GetProperties()) { foreach (var property in ContextType.GetProperties()) {
if (!property.PropertyType.IsGenericType) continue; if (!property.PropertyType.IsGenericType) continue;
@@ -24,7 +32,7 @@ public class DbContextConfig {
/// <summary> /// <summary>
/// A helper class for editing the <see cref="DbContextConfig"/> /// A helper class for editing the <see cref="DbContextConfig"/>
/// </summary> /// </summary>
public class DbContextConfigurator<TDbContext>(DbContextConfig config) { public sealed class DbContextConfigurator<TDbContext>(DbContextConfig config) {
/// <summary> /// <summary>
/// The Internal DbContext configuration that's modified by the helper functions /// 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; namespace HopFrame.Core.Config;
public class HopFrameConfig { public class HopFrameConfig {
public List<DbContextConfig> Contexts { get; } = new(); public List<ITableGroupConfig> Contexts { get; } = new();
public bool DisplayUserInfo { get; set; } = true; public bool DisplayUserInfo { get; set; } = true;
public string? BasePolicy { get; set; } public string? BasePolicy { get; set; }
public string? LoginPageRewrite { get; set; } public string? LoginPageRewrite { get; set; }
public List<HopCallbackHandler> Handlers { get; } = new();
} }
/// <summary> /// <summary>
/// A helper class for editing the <see cref="HopFrameConfig"/> /// A helper class for editing the <see cref="HopFrameConfig"/>
/// </summary> /// </summary>
public class HopFrameConfigurator(HopFrameConfig config) { public sealed class HopFrameConfigurator(HopFrameConfig config, IServiceCollection collection = null!) {
/// <summary> /// <summary>
/// The Internal HopFrame configuration that's modified by the helper functions /// The Internal HopFrame configuration that's modified by the helper functions
/// </summary> /// </summary>
public HopFrameConfig InnerConfig { get; } = config; public HopFrameConfig InnerConfig { get; } = config;
public IServiceCollection ServiceCollection { get; } = collection;
/// <summary> /// <summary>
/// Adds all tables defined in the DbContext to the HopFrame ui and configures it using the provided configurator /// Adds all tables defined in the DbContext to the HopFrame ui and configures it using the provided configurator
/// </summary> /// </summary>
@@ -38,11 +45,64 @@ public class HopFrameConfigurator(HopFrameConfig config) {
/// <returns>The configurator used for the DbContext</returns> /// <returns>The configurator used for the DbContext</returns>
/// <seealso cref="DbContextConfigurator{TDbContext}"/> /// <seealso cref="DbContextConfigurator{TDbContext}"/>
public DbContextConfigurator<TDbContext> AddDbContext<TDbContext>() where TDbContext : DbContext { 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); InnerConfig.Contexts.Add(context);
return new DbContextConfigurator<TDbContext>(context); return new DbContextConfigurator<TDbContext>(context);
} }
/// <summary>
/// Adds a table of the desired type and configures it to use a custom repository
/// </summary>
/// <param name="keyExpression">The key of the model</param>
/// <param name="configurator">The configurator used for configuring the table page</param>
/// <typeparam name="TRepository">The repository class that inherits from the <see cref="IHopFrameRepository{TModel,TKey}"/> (needs to be registered as a service)</typeparam>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <typeparam name="TKey">The type of the primary key</typeparam>
public HopFrameConfigurator AddCustomRepository<TRepository, TModel, TKey>(Expression<Func<TModel, TKey>> keyExpression, Action<TableConfigurator<TModel>> configurator) {
var context = AddCustomRepository<TRepository, TModel, TKey>(keyExpression);
configurator.Invoke(context);
return this;
}
/// <summary>
/// Adds a table of the desired type and configures it to use a custom repository
/// </summary>
/// <param name="keyExpression">The key of the model</param>
/// <typeparam name="TRepository">The repository class that inherits from the <see cref="IHopFrameRepository{TModel,TKey}"/> (needs to be registered as a service)</typeparam>
/// <typeparam name="TModel">The model of the table</typeparam>
/// <typeparam name="TKey">The type of the primary key</typeparam>
/// <returns>The configurator used for configuring the table page</returns>
public TableConfigurator<TModel> AddCustomRepository<TRepository, TModel, TKey>(Expression<Func<TModel, TKey>> keyExpression) {
var keyProperty = TableConfigurator<TModel>.GetPropertyInfo(keyExpression);
var context = new RepositoryGroupConfig(typeof(TRepository), keyProperty, InnerConfig);
context.Tables.Add(new TableConfig(context, typeof(TModel), typeof(TRepository).Name, 0));
InnerConfig.Contexts.Add(context);
return new TableConfigurator<TModel>(context.Tables[0]);
}
/// <summary>
/// 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> /// <summary>
/// Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin ui /// Determines if the name of the currently logged-in user should be displayed in the top right corner of the admin ui
/// </summary> /// </summary>

View File

@@ -11,9 +11,9 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert
public bool Sortable { get; set; } = true; public bool Sortable { get; set; } = true;
public bool Searchable { get; set; } = true; public bool Searchable { get; set; } = true;
public PropertyInfo? DisplayedProperty { get; set; } public PropertyInfo? DisplayedProperty { get; set; }
public Func<object, IServiceProvider, string>? Formatter { get; set; } public Func<object, IServiceProvider, Task<string>>? Formatter { get; set; }
public Func<object, IServiceProvider, string>? EnumerableFormatter { get; set; } public Func<object, IServiceProvider, Task<string>>? EnumerableFormatter { get; set; }
public Func<string, IServiceProvider, object>? Parser { get; set; } public Func<string, IServiceProvider, Task<object>>? Parser { get; set; }
public Func<object?, IServiceProvider, Task<IEnumerable<string>>>? Validator { get; set; } public Func<object?, IServiceProvider, Task<IEnumerable<string>>>? Validator { get; set; }
public bool Editable { get; set; } = true; public bool Editable { get; set; } = true;
public bool Creatable { get; set; } = true; public bool Creatable { get; set; } = true;
@@ -23,8 +23,37 @@ public class PropertyConfig(PropertyInfo info, TableConfig table, int nthPropert
public bool IsRelation { get; internal set; } public bool IsRelation { get; internal set; }
public bool IsRequired { get; internal set; } public bool IsRequired { get; internal set; }
public bool IsEnumerable { 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 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> /// <summary>
@@ -74,7 +103,6 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines if the value that should be displayed instead of the string representation of the type /// Determines if the value that should be displayed instead of the string representation of the type
/// </summary> /// </summary>
/// <seealso cref="Format"/>
public PropertyConfigurator<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) { public PropertyConfigurator<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) {
InnerConfig.DisplayedProperty = TableConfigurator<TProp>.GetPropertyInfo(propertyExpression); InnerConfig.DisplayedProperty = TableConfigurator<TProp>.GetPropertyInfo(propertyExpression);
return this; return this;
@@ -83,9 +111,14 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// <summary> /// <summary>
/// Determines the value that's displayed in the admin ui /// Determines the value that's displayed in the admin ui
/// </summary> /// </summary>
/// <seealso cref="FormatEach{TInnerProp}"/>
/// <seealso cref="SetDisplayedProperty{TInnerProp}"/> /// <seealso cref="SetDisplayedProperty{TInnerProp}"/>
public PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, string> formatter) { public PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, string> formatter) {
InnerConfig.Formatter = (obj, provider) => Task.FromResult(formatter.Invoke((TProp)obj, provider));
return this;
}
/// <inheritdoc cref="Format(System.Func{TProp,System.IServiceProvider,string})"/>
public PropertyConfigurator<TProp> Format(Func<TProp, IServiceProvider, Task<string>> formatter) {
InnerConfig.Formatter = (obj, provider) => formatter.Invoke((TProp)obj, provider); InnerConfig.Formatter = (obj, provider) => formatter.Invoke((TProp)obj, provider);
return this; return this;
} }
@@ -94,6 +127,12 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// Determines the value that's displayed for each entry in the list /// Determines the value that's displayed for each entry in the list
/// </summary> /// </summary>
public PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) { public PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) {
InnerConfig.EnumerableFormatter = (obj, provider) => Task.FromResult(formatter.Invoke((TInnerProp)obj, provider));
return this;
}
/// <inheritdoc cref="FormatEach{TInnerProp}(System.Func{TInnerProp,System.IServiceProvider,string})"/>
public PropertyConfigurator<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, Task<string>> formatter) {
InnerConfig.EnumerableFormatter = (obj, provider) => formatter.Invoke((TInnerProp)obj, provider); InnerConfig.EnumerableFormatter = (obj, provider) => formatter.Invoke((TInnerProp)obj, provider);
return this; return this;
} }
@@ -102,7 +141,13 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
/// Determines the function used for parsing the value provided in the editor dialog to the actual property value /// Determines the function used for parsing the value provided in the editor dialog to the actual property value
/// </summary> /// </summary>
public PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) { public PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) {
InnerConfig.Parser = (str, provider) => parser.Invoke(str, provider)!; InnerConfig.Parser = (str, provider) => Task.FromResult<object>(parser.Invoke(str, provider)!);
return this;
}
/// <inheritdoc cref="SetParser(System.Func{string,System.IServiceProvider,TProp})"/>
public PropertyConfigurator<TProp> SetParser(Func<string, IServiceProvider, Task<TProp>> parser) {
InnerConfig.Parser = async (str, provider) => (await parser.Invoke(str, provider))!;
return this; return this;
} }
@@ -174,4 +219,50 @@ public class PropertyConfigurator<TProp>(PropertyConfig config) {
InnerConfig.Order = index; InnerConfig.Order = index;
return this; return this;
} }
/// <summary>
/// Sets the maximum character length displayed in the admin ui (not in the editor dialog)
/// </summary>
/// <param name="maxLength">The maximum length of characters to be displayed</param>
public PropertyConfigurator<TProp> SetDisplayLength(int maxLength) {
InnerConfig.DisplayLength = maxLength;
return this;
}
/// <summary>
/// Forces a property to be treated as a relation
/// </summary>
/// <param name="isEnumerable">Determines if it is possible to assign multiple objects to the property</param>
/// <param name="isRequired">Determines if the property is nullable</param>
public PropertyConfigurator<TProp> ForceRelation(bool isEnumerable = false, bool isRequired = true) {
InnerConfig.IsRelation = true;
InnerConfig.IsEnumerable = isEnumerable;
InnerConfig.IsRequired = isRequired;
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.ComponentModel.DataAnnotations.Schema;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using HopFrame.Core.Callbacks;
namespace HopFrame.Core.Config; namespace HopFrame.Core.Config;
@@ -10,10 +11,11 @@ public class TableConfig {
public string PropertyName { get; } public string PropertyName { get; }
public string DisplayName { get; set; } public string DisplayName { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public DbContextConfig ContextConfig { get; } public ITableGroupConfig ContextConfig { get; }
public bool Ignored { get; set; } public bool Ignored { get; set; }
public int Order { get; set; } public int Order { get; set; }
internal bool Seeded { get; set; } internal bool Seeded { get; set; }
public bool ShowSearchSuggestions { get; set; } = true;
public string? ViewPolicy { get; set; } public string? ViewPolicy { get; set; }
public string? CreatePolicy { get; set; } public string? CreatePolicy { get; set; }
@@ -22,7 +24,7 @@ public class TableConfig {
public List<PropertyConfig> Properties { get; } = new(); public List<PropertyConfig> Properties { get; } = new();
public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) { public TableConfig(ITableGroupConfig config, Type tableType, string propertyName, int nthTable) {
TableType = tableType; TableType = tableType;
PropertyName = propertyName; PropertyName = propertyName;
ContextConfig = config; ContextConfig = config;
@@ -51,7 +53,7 @@ public class TableConfig {
/// <summary> /// <summary>
/// A helper class for editing the <see cref="TableConfig"/> /// A helper class for editing the <see cref="TableConfig"/>
/// </summary> /// </summary>
public class TableConfigurator<TModel>(TableConfig config) { public sealed class TableConfigurator<TModel>(TableConfig config) {
/// <summary> /// <summary>
/// The Internal property configuration that's modified by the helper functions /// The Internal property configuration that's modified by the helper functions
@@ -65,6 +67,13 @@ public class TableConfigurator<TModel>(TableConfig config) {
InnerConfig.Ignored = ignore; InnerConfig.Ignored = ignore;
return this; return this;
} }
/// <summary>
/// Determines if search suggestions should be displayed in the ui (Advanced Search)
/// </summary>
public TableConfigurator<TModel> ShowSearchSuggestions(bool show = true) {
InnerConfig.ShowSearchSuggestions = show;
return this;
}
/// <summary> /// <summary>
/// Configures the property of the table /// Configures the property of the table
@@ -75,7 +84,7 @@ public class TableConfigurator<TModel>(TableConfig config) {
public PropertyConfigurator<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) { public PropertyConfigurator<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
var info = GetPropertyInfo(propertyExpression); var info = GetPropertyInfo(propertyExpression);
var prop = InnerConfig.Properties var prop = InnerConfig.Properties
.Single(prop => prop.Info.Name == info.Name); .Single(prop => prop.Info == info);
return new PropertyConfigurator<TProp>(prop); return new PropertyConfigurator<TProp>(prop);
} }
@@ -98,14 +107,25 @@ public class TableConfigurator<TModel>(TableConfig config) {
/// <param name="template">The template used for generating the property value</param> /// <param name="template">The template used for generating the property value</param>
/// <returns>The configurator for the virtual property</returns> /// <returns>The configurator for the virtual property</returns>
/// <seealso cref="PropertyConfigurator{TProp}"/> /// <seealso cref="PropertyConfigurator{TProp}"/>
public PropertyConfigurator<string> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template) { public VirtualPropertyConfigurator<TModel> AddVirtualProperty(string name, Func<TModel, IServiceProvider, string> template) {
var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count) { var prop = new VirtualPropertyConfig(InnerConfig, InnerConfig.Properties.Count) {
Name = name, Name = name,
IsListingProperty = true, IsVirtualProperty = true,
Formatter = (obj, provider) => Task.FromResult(template.Invoke((TModel)obj, provider))
};
InnerConfig.Properties.Add(prop);
return new VirtualPropertyConfigurator<TModel>(prop);
}
/// <inheritdoc cref="AddVirtualProperty(string,System.Func{TModel,System.IServiceProvider,string})"/>
public VirtualPropertyConfigurator<TModel> AddVirtualProperty(string name, Func<TModel, IServiceProvider, Task<string>> template) {
var prop = new VirtualPropertyConfig(InnerConfig, InnerConfig.Properties.Count) {
Name = name,
IsVirtualProperty = true,
Formatter = (obj, provider) => template.Invoke((TModel)obj, provider) Formatter = (obj, provider) => template.Invoke((TModel)obj, provider)
}; };
InnerConfig.Properties.Add(prop); InnerConfig.Properties.Add(prop);
return new PropertyConfigurator<string>(prop); return new VirtualPropertyConfigurator<TModel>(prop);
} }
/// <summary> /// <summary>
@@ -178,6 +198,36 @@ public class TableConfigurator<TModel>(TableConfig config) {
return this; 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) { internal static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
if (propertyLambda.Body is not MemberExpression member) { if (propertyLambda.Body is not MemberExpression member) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property."); throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
@@ -187,7 +237,7 @@ public class TableConfigurator<TModel>(TableConfig config) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); 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 && if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
!type.IsSubclassOf(propInfo.ReflectedType)) { !type.IsSubclassOf(propInfo.ReflectedType)) {
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}."); throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");

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

View File

@@ -7,4 +7,5 @@ public interface IContextExplorer {
public TableConfig? GetTable(string tableDisplayName); public TableConfig? GetTable(string tableDisplayName);
public TableConfig? GetTable(Type tableEntity); public TableConfig? GetTable(Type tableEntity);
public ITableManager? GetTableManager(string tablePropertyName); 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; namespace HopFrame.Core.Services;
public interface ITableManager { public interface ITableManager {
public IQueryable<object> LoadPage(int page, int perPage = 20); public Task<IEnumerable<object>> LoadPage(int page, int perPage = 20);
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20); public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20);
public Task<int> TotalPages(int perPage = 20); public Task<int> TotalPages(int perPage = 20);
public Task DeleteItem(object item); public Task DeleteItem(object item);
public Task EditItem(object item); public Task EditItem(object item);
public Task AddItem(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 string DisplayProperty(object? item, PropertyConfig prop, object? value = null); 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,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using HopFrame.Core.Config; using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace HopFrame.Core.Services.Implementations; namespace HopFrame.Core.Services.Implementations;
@@ -44,11 +45,40 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName); var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName);
if (table is null) continue; if (table is null) continue;
var dbContext = provider.GetService(context.ContextType) as DbContext; var repo = provider.GetService(context.ContextType);
if (dbContext is null) return null; if (repo is null) return null;
if (context is DbContextConfig) {
var type = typeof(TableManager<>).MakeGenericType(table.TableType); var type = typeof(TableManager<>).MakeGenericType(table.TableType);
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; return Activator.CreateInstance(type, (DbContext)repo, table, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
if (context is RepositoryGroupConfig repoConfig) {
var type = typeof(RepositoryTableManager<,>).MakeGenericType(table.TableType, repoConfig.KeyProperty.PropertyType);
return Activator.CreateInstance(type, repo, this, provider, provider.GetRequiredService<ISearchExpressionBuilder>()) as ITableManager;
}
}
return null;
}
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; return null;
@@ -56,15 +86,36 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
private void SeedTableData(TableConfig table) { private void SeedTableData(TableConfig table) {
if (table.Seeded) return; if (table.Seeded) return;
var dbContext = (provider.GetService(table.ContextConfig.ContextType) as DbContext)!; if (table.ContextConfig is not DbContextConfig) return;
var dbContext = (provider.GetRequiredService(table.ContextConfig.ContextType) as DbContext)!;
var entity = dbContext.Model.FindEntityType(table.TableType)!; var entity = dbContext.Model.FindEntityType(table.TableType)!;
foreach (var propertyConfig in table.Properties) { 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); var prop = entity.FindProperty(propertyConfig.Info.Name);
if (prop is not null) continue; if (prop is not null) continue;
var nav = entity.FindNavigation(propertyConfig.Info.Name); var nav = entity.FindNavigation(propertyConfig.Info.Name);
if (nav is null) continue; if (nav is null) {
if (!propertyConfig.Info.PropertyType.IsGenericType) continue;
var relationType = propertyConfig.Info.PropertyType.GenericTypeArguments[0];
var isRelation = entity.Model.GetEntityTypes()
.Select(e => e.GetForeignKeys())
.Any(keys => keys
.Select(k => k.PrincipalEntityType.ClrType)
.Any(t => t == relationType || t == table.TableType));
if (!isRelation) continue;
propertyConfig.IsRelation = true;
propertyConfig.IsRequired = false;
propertyConfig.IsEnumerable = true;
continue;
}
propertyConfig.IsRelation = true; propertyConfig.IsRelation = true;
propertyConfig.IsRequired = nav.ForeignKey.IsRequired; propertyConfig.IsRequired = nav.ForeignKey.IsRequired;
propertyConfig.IsEnumerable = nav.IsCollection; propertyConfig.IsEnumerable = nav.IsCollection;
@@ -72,9 +123,9 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
foreach (var property in entity.GetProperties()) { foreach (var property in entity.GetProperties()) {
var propConfig = table.Properties var propConfig = table.Properties
.Where(prop => !prop.IsListingProperty) .Where(prop => !prop.IsVirtualProperty)
.SingleOrDefault(prop => prop.Info == property.PropertyInfo); .SingleOrDefault(prop => prop.Info == property.PropertyInfo);
if (propConfig is null) continue; if (propConfig is null || propConfig.IsRequired) continue;
propConfig.IsRequired = !property.IsNullable; 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,30 +1,46 @@
using System.Collections; using System.Collections;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Metadata;
using HopFrame.Core.Config; using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace HopFrame.Core.Services.Implementations; namespace HopFrame.Core.Services.Implementations;
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class { internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider, ISearchExpressionBuilder searchExpressionBuilder) : ITableManager where TModel : class {
public IQueryable<object> LoadPage(int page, int perPage = 20) { public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 20) {
var table = context.Set<TModel>(); var table = context.Set<TModel>();
var data = IncludeForeignKeys(table); var data = IncludeForeignKeys(table);
return data return await data
.Skip(page * perPage) .Skip(page * perPage)
.Take(perPage); .Take(perPage)
.ToArrayAsync();
} }
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) { public async Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
var table = context.Set<TModel>(); var table = context.Set<TModel>();
var all = IncludeForeignKeys(table)
.AsEnumerable()
.Where(item => ItemSearched(item, searchTerm))
.ToList();
return Task.FromResult(( var parameter = Expression.Parameter(typeof(TModel), "x");
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage), var exp = searchExpressionBuilder.BuildSearchExpression(config, searchTerm, parameter);
(int)Math.Ceiling(all.Count / (double)perPage)));
if (exp is null)
return ([], 0);
var lambda = Expression.Lambda<Func<TModel, bool>>(exp, parameter);
var result = await IncludeForeignKeys(table)
.Where(lambda)
.Skip(page * perPage)
.Take(perPage)
.ToListAsync();
var totalEntries = await table
.Where(lambda)
.CountAsync();
return (result, (int)Math.Ceiling(totalEntries / (double)perPage));
} }
public async Task<int> TotalPages(int perPage = 20) { public async Task<int> TotalPages(int perPage = 20) {
@@ -48,45 +64,38 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public async Task RevertChanges(object item) { public async Task AddAll(IEnumerable<object> items) {
await context.Entry((TModel)item).ReloadAsync(); var table = context.Set<TModel>();
await table.AddRangeAsync(items.Cast<TModel>());
await context.SaveChangesAsync();
} }
private bool ItemSearched(TModel item, string searchTerm) { public async Task<object?> GetOne(object key) {
foreach (var property in config.Properties) { var table = context.Set<TModel>();
if (!property.Searchable) continue; return await table.FindAsync(key);
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<string> DisplayProperty(object? item, PropertyConfig prop, object? value = null, object? enumerableValue = null) {
}
public string DisplayProperty(object? item, PropertyConfig prop, object? value = null) {
if (item is null) return string.Empty; if (item is null) return string.Empty;
if (prop.IsListingProperty) if (prop.IsVirtualProperty)
return prop.Formatter!.Invoke(item, provider); 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) if (propValue is null)
return string.Empty; return string.Empty;
if (prop.Formatter is not null) { if (prop.Formatter is not null) {
return prop.Formatter.Invoke(propValue, provider); return await prop.Formatter.Invoke(propValue, provider);
} }
if (prop.IsEnumerable) { if (prop.IsEnumerable) {
if (value is not null) { if (enumerableValue is not null) {
if (prop.EnumerableFormatter is not null) { if (prop.EnumerableFormatter is not null) {
return prop.EnumerableFormatter.Invoke(value, provider); return await prop.EnumerableFormatter.Invoke(enumerableValue, provider);
} }
return value.ToString() ?? string.Empty; return enumerableValue.ToString() ?? string.Empty;
} }
return (propValue as IEnumerable)!.OfType<object>().Count().ToString(); return (propValue as IEnumerable)!.OfType<object>().Count().ToString();
@@ -103,11 +112,11 @@ internal sealed class TableManager<TModel>(DbContext context, TableConfig config
var innerConfig = explorer.GetTable(propValue.GetType()); var innerConfig = explorer.GetTable(propValue.GetType());
if (innerConfig is null) return propValue.ToString()!; if (innerConfig is null) return propValue.ToString()!;
var innerProp = innerConfig!.Properties 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; if (innerProp is null) return propValue.ToString() ?? string.Empty;
return DisplayProperty(propValue, innerProp); return await DisplayProperty(propValue, innerProp);
} }
private IQueryable<TModel> IncludeForeignKeys(IQueryable<TModel> query) { private IQueryable<TModel> IncludeForeignKeys(IQueryable<TModel> query) {

View File

@@ -0,0 +1,23 @@
@using HopFrame.Web.Components.Pages
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Router AppAssembly="typeof(HopFrameHome).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData"/>
</Found>
</Router>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -1,14 +1,17 @@
@implements IDialogContentComponent<EditorDialogData>
@rendermode InteractiveServer @rendermode InteractiveServer
@implements IDialogContentComponent<EditorDialogData>
@implements IDisposable
@using System.Collections @using System.Collections
@using HopFrame.Core.Config @using HopFrame.Core.Config
@using HopFrame.Core.Services @using HopFrame.Core.Services
@using HopFrame.Web.Models @using HopFrame.Web.Models
@using HopFrame.Web.Helpers @using HopFrame.Web.Helpers
@using HopFrame.Web.Plugins
@using HopFrame.Web.Plugins.Events
<FluentDialogBody> <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; if (!_currentlyEditing && !property.Creatable) continue;
<div style="margin-bottom: 20px"> <div style="margin-bottom: 20px">
@@ -36,11 +39,11 @@
</div> </div>
<div style="display: flex; gap: 5px; margin-bottom: 4px"> <div style="display: flex; gap: 5px; margin-bottom: 4px">
@if (!property.IsRequired) { @if (!property.IsRequired) {
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Relation)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await SetPropertyValue(property, null, InputType.Relation))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
} }
<FluentButton OnClick="async () => await OpenRelationalPicker(property)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await OpenRelationalPicker(property))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -128,7 +131,7 @@
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" /> ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
</div> </div>
<div style="display: flex; gap: 5px"> <div style="display: flex; gap: 5px">
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Enum)" Disabled="@(_currentlyEditing && !property.Editable)"> <FluentButton OnClick="@(async () => await SetPropertyValue(property, null, InputType.Enum))" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -155,7 +158,7 @@
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" /> 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> <FluentLabel Color="@Color.Error">@error</FluentLabel>
} }
</div> </div>
@@ -169,6 +172,7 @@
@inject IHopFrameAuthHandler Handler @inject IHopFrameAuthHandler Handler
@inject IToastService Toasts @inject IToastService Toasts
@inject IServiceProvider Provider @inject IServiceProvider Provider
@inject IPluginOrchestrator PluginOrchestrator
@code { @code {
[Parameter] [Parameter]
@@ -180,6 +184,8 @@
private bool _currentlyEditing; private bool _currentlyEditing;
private ITableManager? _manager; private ITableManager? _manager;
private readonly Dictionary<string, List<string>> _validationErrors = new(); private readonly Dictionary<string, List<string>> _validationErrors = new();
private readonly List<PropertyChange> _changes = new();
private readonly CancellationTokenSource _tokenSource = new();
protected override void OnInitialized() { protected override void OnInitialized() {
_currentlyEditing = Content.CurrentObject is not null; _currentlyEditing = Content.CurrentObject is not null;
@@ -191,20 +197,25 @@
Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType); Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType);
foreach (var property in Content.Config.Properties) { foreach (var property in Content.Config.Properties) {
if (property.IsListingProperty) continue; _validationErrors.Add(property.Name, []);
_validationErrors.Add(property.Info.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) { private TValue? GetPropertyValue<TValue>(PropertyConfig config, object? listItem = null) {
if (!config.DisplayValue) return default; if (!config.DisplayValue) return default;
if (Content.CurrentObject is null) return default; if (Content.CurrentObject is null) return default;
if (listItem is not null) { if (listItem is not null) {
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, listItem); return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, null, listItem).Result;
} }
var value = config.Info.GetValue(Content.CurrentObject); var value = GetNewestValue(config);
if (value is null) if (value is null)
return default; return default;
@@ -213,7 +224,7 @@
return (TValue)value; return (TValue)value;
if (typeof(TValue) == typeof(string)) if (typeof(TValue) == typeof(string))
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config); return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, value).Result;
return (TValue)Convert.ChangeType(value, typeof(TValue)); return (TValue)Convert.ChangeType(value, typeof(TValue));
} }
@@ -277,15 +288,19 @@
else { else {
needsOverride = false; needsOverride = false;
if (!typeof(IList).IsAssignableFrom(config.Info.PropertyType)) { var newItems = ((IEnumerable)value).OfType<object>();
throw new ArgumentException($"Invalid type of '{config.Name}' property in '{config.Table.DisplayName}' table, only list types are supported on enumerable relations.");
var collection = Activator.CreateInstance(config.Info.PropertyType);
var addMethod = config.Info.PropertyType.GetMethod(nameof(ICollection<object>.Add));
if (addMethod is null)
throw new ArgumentException($"Cannot modify property '{config.Name}' on table '{config.Table}' because no 'Add' method is implemented");
foreach (var item in newItems) {
addMethod.Invoke(collection, [item]);
} }
var asList = (IList)config.Info.GetValue(Content.CurrentObject)!; _changes.Add(new PropertyChange(config.Info, collection));
asList.Clear();
foreach (var element in (IEnumerable)value) {
asList.Add(element);
}
} }
break; break;
@@ -295,11 +310,28 @@
} }
if (config.Parser is not null && result is not null) { if (config.Parser is not null && result is not null) {
result = config.Parser(result.ToString()!, Provider); result = await config.Parser(result.ToString()!, Provider);
} }
if (needsOverride) if (needsOverride)
config.Info.SetValue(Content.CurrentObject, result); _changes.Add(new PropertyChange(config.Info, result));
}
private void ApplyChanges(object entry) {
foreach (var prop in Content.Config.Properties) {
var newValue = GetNewestValue(prop);
prop.SetValue(entry, newValue, Provider);
}
}
private object? GetNewestValue(PropertyConfig config) {
var value = config.GetValue(Content.CurrentObject, Provider);
var change = _changes.LastOrDefault(c => c.Property == config.Info);
if (change is not null)
value = change.Value;
return value;
} }
private async Task OpenRelationalPicker(PropertyConfig config) { private async Task OpenRelationalPicker(PropertyConfig config) {
@@ -308,7 +340,7 @@
var relationType = config.Info.PropertyType; var relationType = config.Info.PropertyType;
if (config.IsEnumerable) { if (config.IsEnumerable) {
relationType = config.Info.PropertyType.GetGenericArguments().First(); relationType = relationType.GetGenericArguments().First();
} }
var relationTable = Explorer.GetTable(relationType); var relationTable = Explorer.GetTable(relationType);
@@ -321,7 +353,7 @@
} }
} }
else { else {
var raw = config.Info.GetValue(Content.CurrentObject); var raw = GetNewestValue(config);
if (raw is not null) if (raw is not null)
currentValues.Add(raw); currentValues.Add(raw);
} }
@@ -339,11 +371,11 @@
return false; return false;
foreach (var property in Content.Config.Properties) { 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(); errorList.Clear();
var value = property.Info.GetValue(Content.CurrentObject); var value = GetNewestValue(property);
if (property.Validator is not null) { if (property.Validator is not null) {
errorList.AddRange(await property.Validator.Invoke(value, Provider)); errorList.AddRange(await property.Validator.Invoke(value, Provider));
@@ -352,6 +384,14 @@
if (value is null && property.IsRequired) if (value is null && property.IsRequired)
errorList.Add($"{property.Name} is required"); 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(); StateHasChanged();
@@ -362,10 +402,17 @@
if (!valid) return false; if (!valid) return false;
var dialog = await Dialogs.ShowConfirmationAsync($"Do you really want to {(_currentlyEditing ? "edit" : "create")} this entry?"); var dialog = await Dialogs.ShowConfirmationAsync($"Do you really want to {(_currentlyEditing ? "edit" : "create")} this entry?");
var result = await dialog.Result; var result = await dialog.Result;
return !result.Cancelled; if (result.Cancelled) return false;
ApplyChanges(Content.CurrentObject!);
return true;
} }
private enum InputType { public void Dispose() {
_tokenSource.Dispose();
}
public enum InputType {
Number, Number,
Switch, Switch,
Date, 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,10 +1,14 @@
@using HopFrame.Core.Config @using HopFrame.Core.Config
@using HopFrame.Core.Services @using HopFrame.Core.Services
@using HopFrame.Web.Models
@using Microsoft.Extensions.DependencyInjection @using Microsoft.Extensions.DependencyInjection
@inherits LayoutComponentBase @inherits LayoutComponentBase
<link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" /> <link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" />
<link rel="stylesheet" href="/_content/HopFrame.Web/hopframe.css"/> <link rel="stylesheet" href="@Assets["_content/HopFrame.Web/hopframe.css"]"/>
<link rel="stylesheet" href="@Assets["_content/HopFrame.Web/HopFrame.Web.bundle.scp.css"]"/>
<link rel="stylesheet" href="@Assets["_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css"]"/>
<link rel="stylesheet" href="@Assets["_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.bundle.scp.css"]">
<FluentDesignTheme Mode="DesignThemeModes.Dark" /> <FluentDesignTheme Mode="DesignThemeModes.Dark" />
@@ -36,10 +40,28 @@
@code { @code {
internal static readonly List<CustomView> CustomViews = new();
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
var authorized = await Handler.IsAuthenticatedAsync(Config.BasePolicy); 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) { 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.Config
@using HopFrame.Core.Services @using HopFrame.Core.Services
@using HopFrame.Web.Models
<FluentAppBar Orientation="Orientation.Vertical" PopoverShowSearch="false" Style="background-color: var(--neutral-layer-2); height: auto"> <FluentAppBar Orientation="Orientation.Vertical" PopoverShowSearch="false" Style="background-color: var(--neutral-layer-2); height: auto">
<FluentAppBarItem Href="/admin" <FluentAppBarItem Href="/admin"
@@ -11,6 +12,15 @@
<br> <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)) { @foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) {
<FluentAppBarItem Href="@("/admin/" + table.ToLower())" <FluentAppBarItem Href="@("/admin/" + table.ToLower())"
Match="NavLinkMatch.All" Match="NavLinkMatch.All"
@@ -27,6 +37,7 @@
@code { @code {
private readonly List<TableConfig> _tables = []; private readonly List<TableConfig> _tables = [];
private readonly List<CustomView> _views = [];
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) { foreach (var table in Explorer.GetTables()) {
@@ -34,6 +45,21 @@
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue; if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table); _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" @page "/admin"
@using HopFrame.Core.Config @using HopFrame.Core.Config
@using HopFrame.Core.Services @using HopFrame.Core.Services
@using HopFrame.Web.Models
@layout HopFrameLayout @layout HopFrameLayout
<PageTitle>HopFrame</PageTitle> <PageTitle>HopFrame</PageTitle>
<div style="padding: 1.5rem 1.5rem;"> <div style="padding: 1.5rem 1.5rem;">
<h2>Tables</h2> <h2>Pages</h2>
<FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem"> <FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem">
@foreach (var table in _tables.OrderBy(t => t.Order)) { @foreach (var view in _views) {
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)"> <HopFrameCard
<h3 style="margin-bottom: 0;">@table.DisplayName</h3> Title="@view.Name"
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@table.ViewPolicy</FluentLabel> Subtitle="@view.Policy"
<span>@table.Description</span> Description="@view.Description"
<FluentSpacer /> Href="@view.Url"
<div style="display: flex"> Icon="HopFrameSideMenu.GetLinkIcon(view, IconVariant.Regular)"/>
<FluentSpacer/> }
<a href="@("/admin/" + table.DisplayName.ToLower())" style="display: inline-block"> @foreach (var table in _tables.OrderBy(t => t.Order)) {
<FluentButton>Open</FluentButton> <HopFrameCard
</a> Title="@table.DisplayName"
</div> Subtitle="@table.ViewPolicy"
</FluentCard> Description="@table.Description"
Href="@("/admin/" + table.DisplayName.ToLower())"
Icon="new Icons.Regular.Size24.Database()"/>
} }
</FluentStack> </FluentStack>
</div> </div>
@@ -33,6 +36,7 @@
@code { @code {
private readonly List<TableConfig> _tables = []; private readonly List<TableConfig> _tables = [];
private readonly List<CustomView> _views = [];
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
foreach (var table in Explorer.GetTables()) { foreach (var table in Explorer.GetTables()) {
@@ -40,6 +44,11 @@
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue; if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
_tables.Add(table); _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 @implements IDisposable
@using HopFrame.Core.Config @using HopFrame.Core.Config
@using HopFrame.Core.Callbacks
@using HopFrame.Core.Services @using HopFrame.Core.Services
@using HopFrame.Web.Models @using HopFrame.Web.Models
@using HopFrame.Web.Plugins
@using HopFrame.Web.Plugins.Events
@using HopFrame.Web.Services
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using Microsoft.EntityFrameworkCore
@if (!DisplaySelection) { @if (!DisplaySelection) {
<PageTitle>@_config?.DisplayName</PageTitle> <PageTitle>@_config?.DisplayName</PageTitle>
@@ -18,69 +21,102 @@
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<FluentToolbar Class="hopframe-toolbar"> <FluentToolbar Class="hopframe-toolbar">
<h3>@_config?.DisplayName</h3> <h3>@_config?.DisplayName</h3>
@if (!DisplaySelection) { @if (!DisplaySelection && _buttonToggles.ShowRefreshButton) {
<FluentButton <FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())" IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload" OnClick="@(Reload)"
Loading="_loading" Loading="_loading"
Style="margin-left: 10px"> Style="margin-left: 10px">
Refresh Refresh
</FluentButton> </FluentButton>
} }
<FluentSpacer /> @foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.TopLeft)) {
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" /> <FluentButton
IconStart="@(button.Icon?.GetInstance())"
OnClick="@(() => button.Handler.Invoke(null!, _config!))">
@button.Title
</FluentButton>
}
@if (_hasCreatePolicy && DisplayActions) { <FluentSpacer />
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton> <div
style="position: relative; height: 32px"
class="hopframe-search">
@* ReSharper disable once CSharpWarnings::CS4014 *@
<FluentSearch
@ref="_searchBox"
@oninput="OnSearch"
@onchange="OnSearch"
@onfocusin="() => { SearchFocus(); UpdateSearchSuggestions(); }"
@onfocusout="@(SearchUnfocus)"
Style="width: 500px"/>
@if (_isSearchActive && _searchSuggestions.Count > 0) {
<FluentListbox
TOption="string"
Items="_searchSuggestions"
SelectedOptionChanged="@(SearchSuggestionSelected)"
@onfocusin="@(SearchFocus)"
@onfocusout="@(SearchUnfocus)"/>
}
</div>
@if (_hasCreatePolicy && DisplayActions && _buttonToggles.ShowAddEntityButton) {
<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> </FluentToolbar>
<FluentProgress Visible="_loading" Width="100%" /> <FluentProgress Visible="_loading" Width="100%" />
<div style="display: flex; overflow-y: auto; flex-grow: 1"> <div style="display: flex; overflow-y: auto; flex-grow: 1">
<div style="flex-grow: 1"> <div style="flex-grow: 1">
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()"> <FluentDataGrid Items="CurrentlyDisplayedModels.AsQueryable()">
@if (DisplaySelection) { @if (DisplaySelection) {
<SelectColumn <SelectColumn
TGridItem="object" TGridItem="object"
SelectMode="SelectionMode" SelectMode="SelectionMode"
SelectFromEntireRow="true" SelectFromEntireRow="true"
SelectedItems="DialogData?.SelectedObjects.ToArray()" OnSelect="@(data => SelectItem(data.Item, data.Selected))"
OnSelect="data => SelectItem(data.Item, data.Selected)" SelectAllDisabled="true"
SelectAllChanged="SelectAll" Property="o => DialogData!.SelectedObjects.Contains(o)"
SelectAll="_allSelected" Style="min-width: max-content; height: 44px; display: grid; align-items: center" />
Style="min-width: max-content; height: 44px; display: grid; align-items: center" @ref="_selectColumn" />
} }
@foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) { @foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) {
<PropertyColumn <PropertyColumn
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property, null)" Title="@property.Name" Property="o => DisplayProperty(property, o).Result"
Style="min-width: max-content; height: 44px;" Style="min-width: max-content; height: 44px;"
Sortable="@property.Sortable"/> Sortable="@property.Sortable"/>
} }
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) { @if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
var dataIndex = 0;
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content"> <TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); } @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) { @if (_hasUpdatePolicy && _buttonToggles.ShowEditButton) {
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(currentElement); }"> <FluentButton aria-label="Edit entry" OnClick="@(async () => { await CreateOrEdit(context); })">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/> <FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
</FluentButton> </FluentButton>
} }
@if (_hasDeletePolicy) { @if (_hasDeletePolicy && _buttonToggles.ShowDeleteButton) {
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }"> <FluentButton aria-label="Delete entry" OnClick="@(async () => { await DeleteEntry(context); })">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/> <FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
</FluentButton> </FluentButton>
} }
@{
dataIndex++;
dataIndex %= 20;
}
</TemplateColumn> </TemplateColumn>
} }
</FluentDataGrid> </FluentDataGrid>
@@ -89,7 +125,7 @@
@if (_totalPages > 1) { @if (_totalPages > 1) {
<div class="hopframe-paginator"> <div class="hopframe-paginator">
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)"> <FluentButton BackgroundColor="transparent" OnClick="@(async () => await ChangePage(_currentPage - 1))">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
@@ -104,7 +140,7 @@
<span>of @_totalPages</span> <span>of @_totalPages</span>
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)"> <FluentButton BackgroundColor="transparent" OnClick="@(async () => await ChangePage(_currentPage + 1))">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" /> <FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
</FluentButton> </FluentButton>
</div> </div>
@@ -120,13 +156,34 @@
} }
removeBg(); 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> </script>
<FluentToastProvider MaxToastCount="10" />
<InputFile style="display: none" @ref="FileInputElement" OnChange="@(OnInputFiles)"></InputFile>
@inject IContextExplorer Explorer @inject IContextExplorer Explorer
@inject NavigationManager Navigator @inject NavigationManager Navigator
@inject IJSRuntime Js @inject IJSRuntime Js
@inject IDialogService Dialogs @inject IDialogService Dialogs
@inject IHopFrameAuthHandler Handler @inject IHopFrameAuthHandler Handler
@inject ICallbackEmitter Emitter
@inject IPluginOrchestrator PluginOrchestrator
@inject ISearchSuggestionProvider SearchSuggestions
@code { @code {
@@ -151,20 +208,27 @@
private TableConfig? _config; private TableConfig? _config;
private ITableManager? _manager; private ITableManager? _manager;
private object[] _currentlyDisplayedModels = []; public object[] CurrentlyDisplayedModels = [];
private int _currentPage; private int _currentPage;
private int _totalPages; private int _totalPages;
private string? _searchTerm; private string? _searchTerm;
private bool _loading; private bool _loading;
private bool _isSearchActive;
private IList<string> _searchSuggestions = [];
private FluentSearch? _searchBox;
private bool _hasUpdatePolicy; private bool _hasUpdatePolicy;
private bool _hasDeletePolicy; private bool _hasDeletePolicy;
private bool _hasCreatePolicy; private bool _hasCreatePolicy;
private SelectColumn<object>? _selectColumn; private readonly CancellationTokenSource _tokenSource = new();
private bool _allSelected; private List<PluginButton> _pluginButtons = new();
private DefaultButtonToggles _buttonToggles = new();
internal static HopFrameTablePage? CurrentInstance { get; private set; }
protected override void OnInitialized() { protected override void OnInitialized() {
CurrentInstance = this;
_config ??= Explorer.GetTable(TableDisplayName); _config ??= Explorer.GetTable(TableDisplayName);
if (_config is null || (_config.Ignored && DialogData is null)) { if (_config is null || (_config.Ignored && DialogData is null)) {
@@ -178,12 +242,19 @@
return; 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); _hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy);
_hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy); _hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy);
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy); _hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
_manager ??= Explorer.GetTableManager(_config!.PropertyName); _manager ??= Explorer.GetTableManager(_config!.PropertyName);
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync(); CurrentlyDisplayedModels = (await _manager!.LoadPage(_currentPage, PerPage)).ToArray();
_totalPages = await _manager.TotalPages(PerPage); _totalPages = await _manager.TotalPages(PerPage);
} }
@@ -198,6 +269,7 @@
public void Dispose() { public void Dispose() {
_searchCancel.Dispose(); _searchCancel.Dispose();
_tokenSource.Dispose();
} }
private CancellationTokenSource _searchCancel = new(); private CancellationTokenSource _searchCancel = new();
@@ -206,16 +278,71 @@
_searchTerm = eventArgs.Value?.ToString(); _searchTerm = eventArgs.Value?.ToString();
if (_searchTerm is null) return; if (_searchTerm is null) return;
_searchCancel = new(); _searchCancel = new();
UpdateSearchSuggestions();
await Task.Delay(500, _searchCancel.Token); await Task.Delay(500, _searchCancel.Token);
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(); 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; _loading = true;
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this) {
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) {
_loading = false;
return;
}
if (!string.IsNullOrEmpty(_searchTerm)) { if (!string.IsNullOrEmpty(_searchTerm)) {
(var query, _totalPages) = await _manager!.Search(_searchTerm, 0, PerPage); (var query, _totalPages) = await _manager!.Search(_searchTerm, _currentPage, PerPage);
_currentlyDisplayedModels = query.ToArray(); CurrentlyDisplayedModels = query.ToArray();
} }
else { else {
await OnInitializedAsync(); await OnInitializedAsync();
@@ -223,7 +350,16 @@
_loading = false; _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; if (page < 0 || page > _totalPages - 1) return;
_currentPage = page; _currentPage = page;
await Reload(); await Reload();
@@ -235,11 +371,18 @@
return; 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 dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
var result = await dialog.Result; var result = await dialog.Result;
if (result.Cancelled) return; if (result.Cancelled) return;
await _manager!.DeleteItem(element); await _manager!.DeleteItem(element);
await Emitter.DispatchCallback(CallbackTypes.DeleteEntry(_config!), element);
await Reload(); await Reload();
} }
@@ -249,38 +392,80 @@
return; 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 { var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
TrapFocus = false TrapFocus = false
}); });
var result = await panel.Result; var result = await panel.Result;
var data = result.Data as EditorDialogData; var data = result.Data as EditorDialogData;
if (result.Cancelled) { if (result.Cancelled) return;
if (data?.CurrentObject is not null)
await _manager!.RevertChanges(data.CurrentObject);
return;
}
if (element is null) if (element is null) {
await _manager!.AddItem(data!.CurrentObject!); await _manager!.AddItem(data!.CurrentObject!);
else await Emitter.DispatchCallback(CallbackTypes.CreateEntry(_config!), data.CurrentObject!);
}
else {
await _manager!.EditItem(data!.CurrentObject!); await _manager!.EditItem(data!.CurrentObject!);
await Emitter.DispatchCallback(CallbackTypes.UpdateEntry(_config!), data.CurrentObject!);
}
await Reload(); await Reload();
} }
private void SelectItem(object item, bool selected) { 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) if (!selected)
DialogData?.SelectedObjects.Remove(item); DialogData!.SelectedObjects.Remove(item);
else DialogData?.SelectedObjects.Add(item); else DialogData!.SelectedObjects.Add(item);
} }
private void SelectAll() { private async Task<string> DisplayProperty(PropertyConfig config, object entry) {
var selected = _currentlyDisplayedModels.Any(obj => DialogData?.SelectedObjects.Contains(obj) != true); var display = await _manager!.DisplayProperty(entry, config);
foreach (var displayedModel in _currentlyDisplayedModels) {
SelectItem(displayedModel, selected); if (display.Length > config.DisplayLength)
display = display[..config.DisplayLength] + "...";
return display;
} }
_allSelected = selected; 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; place-items: center;
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
} }
.hopframe-search ::deep fluent-listbox {
width: 500px;
position: absolute;
top: 100%;
left: 0;
background-color: var(--fill-color);
z-index: 1;
outline: none !important;
}

View File

@@ -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,8 @@
using System.Reflection;
namespace HopFrame.Web.Models;
public class PropertyChange(PropertyInfo info, object? value) {
public object? Value { get; set; } = value;
public PropertyInfo Property { get; set; } = info;
}

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,9 +1,16 @@
using HopFrame.Core; using HopFrame.Core;
using HopFrame.Core.Config; using HopFrame.Core.Config;
using HopFrame.Core.Callbacks;
using HopFrame.Web.Components;
using HopFrame.Web.Components.Pages; 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.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Web; namespace HopFrame.Web;
@@ -15,11 +22,12 @@ public static class ServiceCollectionExtensions {
/// <param name="services">The service collection to add the services to</param> /// <param name="services">The service collection to add the services to</param>
/// <param name="configurator">The configurator used to build the HopFrame configuration</param> /// <param name="configurator">The configurator used to build the HopFrame configuration</param>
/// <param name="fluentUiLibraryConfiguration">The configuration for the FluentUI components</param> /// <param name="fluentUiLibraryConfiguration">The configuration for the FluentUI components</param>
/// <param name="addRazorComponents">Set this to false if you don't want to automatically configure razor components with interactive server components</param>
/// <returns>The same service collection that is passed in</returns> /// <returns>The same service collection that is passed in</returns>
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null) { public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null, bool addRazorComponents = true) {
var config = new HopFrameConfig(); var config = new HopFrameConfig();
configurator.Invoke(new HopFrameConfigurator(config)); configurator.Invoke(new HopFrameConfigurator(config, services));
return AddHopFrame(services, config, fluentUiLibraryConfiguration); return AddHopFrame(services, config, fluentUiLibraryConfiguration, addRazorComponents);
} }
/// <summary> /// <summary>
@@ -28,22 +36,50 @@ public static class ServiceCollectionExtensions {
/// <param name="services">The service collection to add the services to</param> /// <param name="services">The service collection to add the services to</param>
/// <param name="config">The config used for the HopFrame admin ui</param> /// <param name="config">The config used for the HopFrame admin ui</param>
/// <param name="fluentUiLibraryConfiguration">The configuration for the FluentUI components</param> /// <param name="fluentUiLibraryConfiguration">The configuration for the FluentUI components</param>
/// <param name="addRazorComponents">Set this to false if you don't want to automatically configure razor components with interactive server components</param>
/// <returns>The same service collection that is passed in</returns> /// <returns>The same service collection that is passed in</returns>
public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config, LibraryConfiguration? fluentUiLibraryConfiguration = null) { public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config, LibraryConfiguration? fluentUiLibraryConfiguration = null, bool addRazorComponents = true) {
services.AddSingleton(config); services.AddSingleton(config);
services.AddHopFrameServices(); services.AddHopFrameServices();
services.AddFluentUIComponents(fluentUiLibraryConfiguration); services.AddFluentUIComponents(fluentUiLibraryConfiguration);
services.AddScoped<IPluginOrchestrator, PluginOrchestrator>();
services.AddScoped<IFileService, FileService>();
services.TryAddScoped<ISearchSuggestionProvider, SearchSuggestionProvider>();
if (addRazorComponents) {
services.AddRazorComponents()
.AddInteractiveServerComponents();
}
return services; return services;
} }
/// <summary> /// <summary>
/// Maps the HopFrame admin ui endpoints /// Adds the HopFrame admin ui endpoints
/// </summary> /// </summary>
/// <seealso cref="AddHopFramePages"/>
[Obsolete($"Use '{nameof(AddHopFramePages)}' instead")]
public static RazorComponentsEndpointConventionBuilder MapHopFramePages(this RazorComponentsEndpointConventionBuilder builder) { public static RazorComponentsEndpointConventionBuilder MapHopFramePages(this RazorComponentsEndpointConventionBuilder builder) {
return AddHopFramePages(builder);
}
/// <summary>
/// Adds the HopFrame admin ui endpoints
/// </summary>
public static RazorComponentsEndpointConventionBuilder AddHopFramePages(this RazorComponentsEndpointConventionBuilder builder) {
builder builder
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(HopFrameHome).Assembly); .AddAdditionalAssemblies(typeof(HopFrameHome).Assembly);
return builder; return builder;
} }
public static WebApplication MapHopFrame(this WebApplication app) {
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
return app;
}
} }

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

@@ -40,3 +40,36 @@
fluent-option { fluent-option {
background: transparent !important; background: transparent !important;
} }
body {
--body-font: "Segoe UI Variable", "Segoe UI", sans-serif;
font-family: var(--body-font), sans-serif;
font-size: var(--type-ramp-base-font-size);
line-height: var(--type-ramp-base-line-height);
margin: 0;
}
footer {
background: var(--neutral-layer-4);
color: var(--neutral-foreground-rest);
align-items: center;
padding: 10px 10px;
}
footer a {
color: var(--neutral-foreground-rest);
text-decoration: none;
}
footer a:focus {
outline: 1px dashed;
outline-offset: 3px;
}
footer a:hover {
text-decoration: underline;
}
.column-header.select-all > svg {
display: none;
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Testing\HopFrame.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,51 @@
using HopFrame.Testing;
using HopFrame.Web;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddHopFrame(options => {
options.AddDbContext<DatabaseContext>();
});
builder.Services.AddDbContext<DatabaseContext>(options => {
options.UseInMemoryDatabase("testing.web");
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
}
app.UseHttpsRedirection();
var summaries = new[] {
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () => {
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.MapHopFrame();
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) {
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5115",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7129;http://localhost:5115",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

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

View File

@@ -12,8 +12,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Post>() modelBuilder.Entity<Post>()
.HasOne<User>(p => p.Author) .HasOne(p => p.Author)
.WithMany(u => u.Posts) .WithMany(u => u.Posts);
.OnDelete(DeleteBehavior.Cascade);
} }
} }

View File

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

View File

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

View File

@@ -12,8 +12,4 @@ public class User {
public string? LastName { get; set; } public string? LastName { get; set; }
public virtual List<Post> Posts { get; set; } = new(); public virtual List<Post> Posts { get; set; } = new();
public override string ToString() {
return Username;
}
} }

View File

@@ -1,11 +1,10 @@
using System.Collections;
using HopFrame.Testing; using HopFrame.Testing;
using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components;
using HopFrame.Testing.Components; using HopFrame.Testing.Components;
using HopFrame.Testing.Models; using HopFrame.Testing.Models;
using HopFrame.Web; using HopFrame.Web;
using HopFrame.Web.Components.Pages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Message = HopFrame.Testing.Models.Message;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -36,6 +35,11 @@ builder.Services.AddHopFrame(options => {
.SetOrderIndex(3); .SetOrderIndex(3);
table.AddVirtualProperty("Name", (user, _) => $"{user.FirstName} {user.LastName}") 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); .SetOrderIndex(2);
table.SetDisplayName("Benutzer"); table.SetDisplayName("Benutzer");
@@ -47,9 +51,14 @@ builder.Services.AddHopFrame(options => {
.FormatEach<Post>((post, _) => post.Caption); .FormatEach<Post>((post, _) => post.Caption);
}); });
context.Table<Post>()
.ShowSearchSuggestions(false);
context.Table<Post>() context.Table<Post>()
.Property(p => p.Author) .Property(p => p.Author)
.Format((user, _) => $"{user.FirstName} {user.LastName}"); //.Format((user, _) => $"{user.FirstName} {user.LastName}")
.SetDisplayedProperty(u => u.Username)
.SetValidator((_, _) => []);
context.Table<Post>() context.Table<Post>()
.Property(p => p.Id) .Property(p => p.Id)
@@ -60,6 +69,7 @@ builder.Services.AddHopFrame(options => {
context.Table<Post>() context.Table<Post>()
.Property(p => p.Content) .Property(p => p.Content)
.SetDisplayLength(100)
.IsTextArea(true) .IsTextArea(true)
/*.Validator(input => { /*.Validator(input => {
var errors = new List<string>(); var errors = new List<string>();
@@ -73,11 +83,41 @@ builder.Services.AddHopFrame(options => {
return errors; return errors;
})*/; })*/;
context.Table<Post>() /*context.Table<Post>()
.SetOrderIndex(-1); .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(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
@@ -94,6 +134,6 @@ app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.MapHopFramePages(); .AddHopFramePages();
app.Run(); app.Run();

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,69 +17,69 @@ public class DisplayPropertyTests {
var contextMock = new Mock<DbContext>(); var contextMock = new Mock<DbContext>();
_providerMock = new Mock<IServiceProvider>(); _providerMock = new Mock<IServiceProvider>();
_explorerMock = new Mock<IContextExplorer>(); _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 = _tableManager =
new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object); new TableManager<object>(contextMock.Object, _config, _explorerMock.Object, _providerMock.Object, new SearchExpressionBuilder(_explorerMock.Object));
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsEmptyString_WhenItemIsNull() { public async Task DisplayProperty_ReturnsEmptyString_WhenItemIsNull() {
// Arrange // Arrange
var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0); var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0);
// Act // Act
var result = _tableManager.DisplayProperty(null, prop); var result = await _tableManager.DisplayProperty(null, prop);
// Assert // Assert
Assert.Equal(string.Empty, result); Assert.Equal(string.Empty, result);
} }
[Fact] [Fact]
public void DisplayProperty_UsesFormatter_WhenListingProperty() { public async Task DisplayProperty_UsesFormatter_WhenListingProperty() {
// Arrange // Arrange
var item = "test"; var item = "test";
var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0) { var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0) {
IsListingProperty = true, IsVirtualProperty = true,
Formatter = (obj, provider) => ((string)obj).ToUpper() Formatter = (obj, provider) => Task.FromResult(((string)obj).ToUpper())
}; };
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("TEST", result); Assert.Equal("TEST", result);
} }
[Fact] [Fact]
public void DisplayProperty_UsesValueFormatter_WhenNotListingProperty() { public async Task DisplayProperty_UsesValueFormatter_WhenNotListingProperty() {
// Arrange // Arrange
var item = "test"; var item = "test";
var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0) { var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0) {
Formatter = (obj, provider) => ((int)obj).ToString("D4") Formatter = (obj, provider) => Task.FromResult(((int)obj).ToString("D4"))
}; };
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("0004", result); Assert.Equal("0004", result);
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsValueAsString_WhenNoFormatter() { public async Task DisplayProperty_ReturnsValueAsString_WhenNoFormatter() {
// Arrange // Arrange
var item = "test"; var item = "test";
var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0); var prop = new PropertyConfig(typeof(string).GetProperty("Length")!, _config, 0);
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("4", result); Assert.Equal("4", result);
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsEnumerableCount_WhenEnumerableProperty() { public async Task DisplayProperty_ReturnsEnumerableCount_WhenEnumerableProperty() {
// Arrange // Arrange
var item = new { List = new List<int> { 1, 2, 3 } }; var item = new { List = new List<int> { 1, 2, 3 } };
var prop = new PropertyConfig(item.GetType().GetProperty("List")!, _config, 0) { var prop = new PropertyConfig(item.GetType().GetProperty("List")!, _config, 0) {
@@ -87,14 +87,14 @@ public class DisplayPropertyTests {
}; };
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("3", result); Assert.Equal("3", result);
} }
[Fact] [Fact]
public void DisplayProperty_UsesDisplayedProperty_WhenNoDirectFormatter() { public async Task DisplayProperty_UsesDisplayedProperty_WhenNoDirectFormatter() {
// Arrange // Arrange
var item = new { Inner = new { Key = 42 } }; var item = new { Inner = new { Key = 42 } };
var innerPropInfo = item.Inner.GetType().GetProperty("Key"); var innerPropInfo = item.Inner.GetType().GetProperty("Key");
@@ -106,48 +106,48 @@ public class DisplayPropertyTests {
_explorerMock _explorerMock
.Setup(e => e.GetTable(item.Inner.GetType())) .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 } Properties = { innerPropConfig }
}); });
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("42", result); Assert.Equal("42", result);
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsEmptyString_WhenPropValueIsNull() { public async Task DisplayProperty_ReturnsEmptyString_WhenPropValueIsNull() {
// Arrange // Arrange
var item = new { Name = (string?)null }; var item = new { Name = (string?)null };
var prop = new PropertyConfig(item.GetType().GetProperty("Name")!, _config, 0); var prop = new PropertyConfig(item.GetType().GetProperty("Name")!, _config, 0);
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal(string.Empty, result); Assert.Equal(string.Empty, result);
} }
[Fact] [Fact]
public void DisplayProperty_UsesEnumerableFormatter_WhenEnumerableAndValueProvided() { public async Task DisplayProperty_UsesEnumerableFormatter_WhenEnumerableAndValueProvided() {
// Arrange // Arrange
var item = new { List = new List<int> { 1, 2, 3 } }; var item = new { List = new List<int> { 1, 2, 3 } };
var prop = new PropertyConfig(item.GetType().GetProperty("List")!, _config, 0) { var prop = new PropertyConfig(item.GetType().GetProperty("List")!, _config, 0) {
IsEnumerable = true, IsEnumerable = true,
EnumerableFormatter = (obj, provider) => string.Join(",", ((IEnumerable<int>)obj)) EnumerableFormatter = (obj, provider) => Task.FromResult(string.Join(",", ((IEnumerable<int>)obj)))
}; };
// Act // Act
var result = _tableManager.DisplayProperty(item, prop, item.List); var result = await _tableManager.DisplayProperty(item, prop, null, item.List);
// Assert // Assert
Assert.Equal("1,2,3", result); Assert.Equal("1,2,3", result);
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsEmptyString_WhenDisplayedPropertyAndInnerConfigIsNull() { public async Task DisplayProperty_ReturnsEmptyString_WhenDisplayedPropertyAndInnerConfigIsNull() {
// Arrange // Arrange
var item = new { Inner = new { Key = 42 } }; var item = new { Inner = new { Key = 42 } };
var innerPropInfo = item.Inner.GetType().GetProperty("Key"); var innerPropInfo = item.Inner.GetType().GetProperty("Key");
@@ -161,14 +161,14 @@ public class DisplayPropertyTests {
.Returns((TableConfig?)null); .Returns((TableConfig?)null);
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("{ Key = 42 }", result); // Returns the value as string if inner config is null Assert.Equal("{ Key = 42 }", result); // Returns the value as string if inner config is null
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsKeyValue_WhenDisplayedPropertyIsNull() { public async Task DisplayProperty_ReturnsKeyValue_WhenDisplayedPropertyIsNull() {
// Arrange // Arrange
var item = new { Inner = new { Key = 42 } }; var item = new { Inner = new { Key = 42 } };
var propInfo = item.GetType().GetProperty("Inner"); var propInfo = item.GetType().GetProperty("Inner");
@@ -177,21 +177,21 @@ public class DisplayPropertyTests {
var keyProperty = item.Inner.GetType().GetProperty("Key"); var keyProperty = item.Inner.GetType().GetProperty("Key");
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("{ Key = 42 }", result); // Returns key value as string if DisplayedProperty is null Assert.Equal("{ Key = 42 }", result); // Returns key value as string if DisplayedProperty is null
} }
[Fact] [Fact]
public void DisplayProperty_ReturnsToStringValue_WhenNoKeyOrDisplayedProperty() { public async Task DisplayProperty_ReturnsToStringValue_WhenNoKeyOrDisplayedProperty() {
// Arrange // Arrange
var item = new { Inner = new { Name = "Test" } }; var item = new { Inner = new { Name = "Test" } };
var propInfo = item.GetType().GetProperty("Inner"); var propInfo = item.GetType().GetProperty("Inner");
var prop = new PropertyConfig(propInfo!, _config, 0); var prop = new PropertyConfig(propInfo!, _config, 0);
// Act // Act
var result = _tableManager.DisplayProperty(item, prop); var result = await _tableManager.DisplayProperty(item, prop);
// Assert // Assert
Assert.Equal("{ Name = Test }", result); // Returns ToString value of inner property Assert.Equal("{ Name = Test }", result); // Returns ToString value of inner property

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