Merge branch 'feature/setup' into 'dev'
Feature/setup Closes #12 See merge request leon.hoppe/hopframe!16
This commit was merged in pull request #54.
This commit is contained in:
276
.idea/.idea.HopFrame/.idea/workspace.xml
generated
276
.idea/.idea.HopFrame/.idea/workspace.xml
generated
@@ -1,32 +1,143 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoGeneratedRunConfigurationManager">
|
||||
<projectFile profileName="http">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
|
||||
<projectFile profileName="https">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
|
||||
<projectFile profileName="http">testing/HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
|
||||
</component>
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="" />
|
||||
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="Added text area support and DI support for modifier functions">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="DpaMonitoringSettings">
|
||||
<option name="firstShow" value="false" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
<option name="RECENT_TEMPLATES">
|
||||
<list>
|
||||
<option value="CSS File" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="dev" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo"><![CDATA[{
|
||||
"associatedIndex": 3
|
||||
}]]></component>
|
||||
<component name="GitLabMergeRequestFiltersHistory">{
|
||||
"lastFilter": {
|
||||
"state": "OPENED",
|
||||
"assignee": {
|
||||
"type": "org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue.MergeRequestsMemberFilterValue.MergeRequestsAssigneeFilterValue",
|
||||
"username": "leon.hoppe",
|
||||
"fullname": "Leon Hoppe"
|
||||
}
|
||||
}
|
||||
}</component>
|
||||
<component name="GitLabMergeRequestsSettings">{
|
||||
"selectedUrlAndAccountId": {
|
||||
"first": "https://git.leon-hoppe.de/leon.hoppe/hopframe.git",
|
||||
"second": "2d65fdcb-5f13-45ad-a7ba-91dd4a88d6e4"
|
||||
}
|
||||
}</component>
|
||||
<component name="HighlightingSettingsPerFile">
|
||||
<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/60e7b22380df80ef6fefe43138047f49ec6eff4b25c12b42ce3d6ed5aac/MethodInvokerCommon.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/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/d858ddb35a8e36df5573b7612542f9ad50f426b8ab43818587d1ac65fab14829/DatabaseGeneratedAttribute.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/ff37d54b3bf4d2756237fb789635831532603376e940f63d634b869d26d74c/Regular16.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Config/PropertyConfig.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Config/TableConfig.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/TableManager.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
|
||||
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/testing/HopFrame.Testing/Program.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
</component>
|
||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 3
|
||||
}</component>
|
||||
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
|
||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
|
||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
|
||||
<ConfirmationsSetting value="2" id="Add" />
|
||||
</component>
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.environmentSetup",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
".NET Launch Settings Profile.HopFrame.Testing.executor": "Run",
|
||||
".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run",
|
||||
".NET Project.HopFrame.Testing.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "feature/setup",
|
||||
"list.type.of.created.stylesheet": "CSS",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.environmentSetup",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
}</component>
|
||||
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
|
||||
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="HopFrame.Testing: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="https" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<list>
|
||||
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: https" />
|
||||
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: http" />
|
||||
</list>
|
||||
</component>
|
||||
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
@@ -36,14 +147,155 @@
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1736788802057</updated>
|
||||
<workItem from="1736788803805" duration="46000" />
|
||||
<workItem from="1736788853462" duration="780000" />
|
||||
<workItem from="1736845367516" duration="283000" />
|
||||
<workItem from="1736845655122" duration="165000" />
|
||||
<workItem from="1736845825812" duration="14084000" />
|
||||
<workItem from="1736863626597" duration="7027000" />
|
||||
<workItem from="1736875984621" duration="8464000" />
|
||||
<workItem from="1736884461354" duration="1075000" />
|
||||
<workItem from="1736962119221" duration="8119000" />
|
||||
<workItem from="1737021098746" duration="21112000" />
|
||||
<workItem from="1737047730756" duration="7678000" />
|
||||
<workItem from="1737120164342" duration="9351000" />
|
||||
<workItem from="1737199714142" duration="2964000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Added basic configuration">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736850899254</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736850899254</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="Added admin page navigation">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736855209077</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736855209077</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="Added database loading logic">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736859917232</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736859917232</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="Started working on listing page">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736885531216</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736885531216</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="Added entry saving support">
|
||||
<option name="closed" value="true" />
|
||||
<created>1736970238802</created>
|
||||
<option name="number" value="00005" />
|
||||
<option name="presentableId" value="LOCAL-00005" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1736970238802</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00006" summary="Added reload button and animation">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737023058093</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737023058093</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00007" summary="Added relation picker dialog">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737035288104</created>
|
||||
<option name="number" value="00007" />
|
||||
<option name="presentableId" value="LOCAL-00007" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737035288104</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00008" summary="Added automatic relation mapping">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737037853482</created>
|
||||
<option name="number" value="00008" />
|
||||
<option name="presentableId" value="LOCAL-00008" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737037853482</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00009" summary="Added property validation">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737040612038</created>
|
||||
<option name="number" value="00009" />
|
||||
<option name="presentableId" value="LOCAL-00009" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737040612038</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00010" summary="Added creation/modification confirmation">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737040946489</created>
|
||||
<option name="number" value="00010" />
|
||||
<option name="presentableId" value="LOCAL-00010" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737040946489</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00011" summary="Removed Template">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737042229086</created>
|
||||
<option name="number" value="00011" />
|
||||
<option name="presentableId" value="LOCAL-00011" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737042229086</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00012" summary="Added policy validation, ordering and virtual listing properties">
|
||||
<option name="closed" value="true" />
|
||||
<created>1737055409534</created>
|
||||
<option name="number" value="00012" />
|
||||
<option name="presentableId" value="LOCAL-00012" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1737055409535</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00013" summary="Added n -> 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>
|
||||
<option name="localTasksCounter" value="15" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
||||
<component name="VcsManagerConfiguration">
|
||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||
<MESSAGE value="Added basic configuration" />
|
||||
<MESSAGE value="Added admin page navigation" />
|
||||
<MESSAGE value="Added database loading logic" />
|
||||
<MESSAGE value="Started working on listing page" />
|
||||
<MESSAGE value="Added entry saving support" />
|
||||
<MESSAGE value="Added reload button and animation" />
|
||||
<MESSAGE value="Added relation picker dialog" />
|
||||
<MESSAGE value="Added automatic relation mapping" />
|
||||
<MESSAGE value="Added property validation" />
|
||||
<MESSAGE value="Added creation/modification confirmation" />
|
||||
<MESSAGE value="Removed Template" />
|
||||
<MESSAGE value="Added policy validation, ordering and virtual listing properties" />
|
||||
<MESSAGE value="Added n -> m relation support" />
|
||||
<MESSAGE value="Added text area support and DI support for modifier functions" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Added text area support and DI support for modifier functions" />
|
||||
</component>
|
||||
</project>
|
||||
29
HopFrame.sln
29
HopFrame.sln
@@ -1,8 +1,37 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{7E4AAFB3-9762-4F42-86DF-5A3194FDC243}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Core", "src\HopFrame.Core\HopFrame.Core.csproj", "{4BFE21C2-EAAC-4662-8B97-500836651B2A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{9EB7FDBD-49C2-4872-9666-6F7AEBA541B2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Testing", "testing\HopFrame.Testing\HopFrame.Testing.csproj", "{58490069-51DF-454C-8B54-7FB7D4BDFF81}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{4BFE21C2-EAAC-4662-8B97-500836651B2A} = {7E4AAFB3-9762-4F42-86DF-5A3194FDC243}
|
||||
{8E59F398-184A-47C9-AAA2-3E0FFD775ABF} = {7E4AAFB3-9762-4F42-86DF-5A3194FDC243}
|
||||
{58490069-51DF-454C-8B54-7FB7D4BDFF81} = {9EB7FDBD-49C2-4872-9666-6F7AEBA541B2}
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4BFE21C2-EAAC-4662-8B97-500836651B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4BFE21C2-EAAC-4662-8B97-500836651B2A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{58490069-51DF-454C-8B54-7FB7D4BDFF81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{58490069-51DF-454C-8B54-7FB7D4BDFF81}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{58490069-51DF-454C-8B54-7FB7D4BDFF81}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{58490069-51DF-454C-8B54-7FB7D4BDFF81}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
38
src/HopFrame.Core/Config/DbContextConfig.cs
Normal file
38
src/HopFrame.Core/Config/DbContextConfig.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class DbContextConfig {
|
||||
public Type ContextType { get; }
|
||||
public List<TableConfig> Tables { get; } = new();
|
||||
|
||||
public DbContextConfig(Type context) {
|
||||
ContextType = context;
|
||||
|
||||
foreach (var property in ContextType.GetProperties()) {
|
||||
if (!property.PropertyType.IsGenericType) continue;
|
||||
var innerType = property.PropertyType.GenericTypeArguments.First();
|
||||
var setType = typeof(DbSet<>).MakeGenericType(innerType);
|
||||
if (property.PropertyType != setType) continue;
|
||||
|
||||
var table = new TableConfig(this, innerType, property.Name, Tables.Count);
|
||||
Tables.Add(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DbContextConfig<TDbContext>(DbContextConfig config) {
|
||||
public DbContextConfig InnerConfig { get; } = config;
|
||||
|
||||
public DbContextConfig<TDbContext> Table<TModel>(Action<TableConfig<TModel>> configurator) where TModel : class {
|
||||
var table = Table<TModel>();
|
||||
configurator.Invoke(table);
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> Table<TModel>() where TModel : class {
|
||||
var table = InnerConfig.Tables.Single(table => table.TableType == typeof(TModel));
|
||||
return new TableConfig<TModel>(table);
|
||||
}
|
||||
|
||||
}
|
||||
42
src/HopFrame.Core/Config/HopFrameConfig.cs
Normal file
42
src/HopFrame.Core/Config/HopFrameConfig.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using HopFrame.Core.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class HopFrameConfig {
|
||||
public List<DbContextConfig> Contexts { get; } = new();
|
||||
public bool DisplayUserInfo { get; set; } = true;
|
||||
public string? BasePolicy { get; set; }
|
||||
public string? LoginPageRewrite { get; set; }
|
||||
}
|
||||
|
||||
public class HopFrameConfigurator(HopFrameConfig config) {
|
||||
public HopFrameConfig InnerConfig { get; } = config;
|
||||
|
||||
public HopFrameConfigurator AddDbContext<TDbContext>(Action<DbContextConfig<TDbContext>> configurator) where TDbContext : DbContext {
|
||||
var context = AddDbContext<TDbContext>();
|
||||
configurator.Invoke(context);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DbContextConfig<TDbContext> AddDbContext<TDbContext>() where TDbContext : DbContext {
|
||||
var context = new DbContextConfig(typeof(TDbContext));
|
||||
InnerConfig.Contexts.Add(context);
|
||||
return new DbContextConfig<TDbContext>(context);
|
||||
}
|
||||
|
||||
public HopFrameConfigurator DisplayUserInfo(bool display) {
|
||||
InnerConfig.DisplayUserInfo = display;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HopFrameConfigurator SetBasePolicy(string basePolicy) {
|
||||
InnerConfig.BasePolicy = basePolicy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HopFrameConfigurator SetLoginPage(string url) {
|
||||
InnerConfig.LoginPageRewrite = url;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
114
src/HopFrame.Core/Config/PropertyConfig.cs
Normal file
114
src/HopFrame.Core/Config/PropertyConfig.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Collections;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class PropertyConfig(PropertyInfo info, TableConfig table, int nthProperty) {
|
||||
public PropertyInfo Info { get; } = info;
|
||||
public TableConfig Table { get; } = table;
|
||||
public string Name { get; set; } = info.Name;
|
||||
public bool List { get; set; } = true;
|
||||
public bool Sortable { get; set; } = true;
|
||||
public bool Searchable { get; set; } = true;
|
||||
public PropertyInfo? DisplayedProperty { get; set; }
|
||||
public Func<object, IServiceProvider, string>? Formatter { get; set; }
|
||||
public Func<object, IServiceProvider, string>? EnumerableFormatter { get; set; }
|
||||
public Func<string, IServiceProvider, object>? Parser { get; set; }
|
||||
public Func<object?, IServiceProvider, Task<IEnumerable<string>>>? Validator { get; set; }
|
||||
public bool Editable { get; set; } = true;
|
||||
public bool Creatable { get; set; } = true;
|
||||
public bool DisplayValue { get; set; } = true;
|
||||
public bool TextArea { get; set; }
|
||||
public int TextAreaRows { get; set; } = 16;
|
||||
public bool IsRelation { get; set; }
|
||||
public bool IsRequired { get; set; }
|
||||
public bool IsEnumerable { get; set; }
|
||||
public bool IsListingProperty { get; set; }
|
||||
public int Order { get; set; } = nthProperty;
|
||||
}
|
||||
|
||||
public class PropertyConfig<TProp>(PropertyConfig config) {
|
||||
public PropertyConfig InnerConfig { get; } = config;
|
||||
|
||||
public PropertyConfig<TProp> SetDisplayName(string displayName) {
|
||||
InnerConfig.Name = displayName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> List(bool list) {
|
||||
InnerConfig.List = list;
|
||||
InnerConfig.Searchable = !list;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> IsSortable(bool sortable) {
|
||||
InnerConfig.Sortable = sortable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> IsSearchable(bool searchable) {
|
||||
InnerConfig.Searchable = searchable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) {
|
||||
InnerConfig.DisplayedProperty = TableConfig<TProp>.GetPropertyInfo(propertyExpression);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> Format(Func<TProp, IServiceProvider, string> formatter) {
|
||||
InnerConfig.Formatter = (obj, provider) => formatter.Invoke((TProp)obj, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) {
|
||||
InnerConfig.EnumerableFormatter = (obj, provider) => formatter.Invoke((TInnerProp)obj, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) {
|
||||
InnerConfig.Parser = (str, provider) => parser.Invoke(str, provider)!;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetEditable(bool editable) {
|
||||
InnerConfig.Editable = editable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetCreatable(bool creatable) {
|
||||
InnerConfig.Creatable = creatable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> DisplayValue(bool display) {
|
||||
InnerConfig.DisplayValue = display;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> IsTextArea(bool textField) {
|
||||
InnerConfig.TextArea = textField;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetTextAreaRows(int rows) {
|
||||
InnerConfig.TextAreaRows = rows;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetValidator(Func<TProp?, IServiceProvider, IEnumerable<string>> validator) {
|
||||
InnerConfig.Validator = (obj, provider) => Task.FromResult(validator.Invoke((TProp?)obj, provider));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetValidator(Func<TProp?, IServiceProvider, Task<IEnumerable<string>>> validator) {
|
||||
InnerConfig.Validator = (obj, provider) => validator.Invoke((TProp?)obj, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> SetOrderIndex(int index) {
|
||||
InnerConfig.Order = index;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
144
src/HopFrame.Core/Config/TableConfig.cs
Normal file
144
src/HopFrame.Core/Config/TableConfig.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class TableConfig {
|
||||
public Type TableType { get; }
|
||||
public string PropertyName { get; }
|
||||
public string DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DbContextConfig ContextConfig { get; }
|
||||
public bool Ignored { get; set; }
|
||||
public int Order { get; set; }
|
||||
internal bool Seeded { get; set; }
|
||||
|
||||
public string? ViewPolicy { get; set; }
|
||||
public string? CreatePolicy { get; set; }
|
||||
public string? UpdatePolicy { get; set; }
|
||||
public string? DeletePolicy { get; set; }
|
||||
|
||||
public List<PropertyConfig> Properties { get; } = new();
|
||||
|
||||
public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) {
|
||||
TableType = tableType;
|
||||
PropertyName = propertyName;
|
||||
ContextConfig = config;
|
||||
DisplayName = PropertyName;
|
||||
Order = nthTable;
|
||||
|
||||
var properties = tableType.GetProperties();
|
||||
for (var i = 0; i < properties.Length; i++) {
|
||||
var info = properties[i];
|
||||
var propConfig = new PropertyConfig(info, this, i);
|
||||
|
||||
if (info.GetCustomAttributes(true).Any(a => a is DatabaseGeneratedAttribute)) {
|
||||
propConfig.Creatable = false;
|
||||
propConfig.Editable = false;
|
||||
}
|
||||
|
||||
if (info.GetCustomAttributes(true).Any(a => a is KeyAttribute)) {
|
||||
propConfig.Editable = false;
|
||||
}
|
||||
|
||||
Properties.Add(propConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class TableConfig<TModel>(TableConfig config) {
|
||||
public TableConfig InnerConfig { get; } = config;
|
||||
|
||||
public TableConfig<TModel> Ignore() {
|
||||
InnerConfig.Ignored = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
|
||||
var info = GetPropertyInfo(propertyExpression);
|
||||
var prop = InnerConfig.Properties
|
||||
.Single(prop => prop.Info.Name == info.Name);
|
||||
return new PropertyConfig<TProp>(prop);
|
||||
}
|
||||
|
||||
public TableConfig<TModel> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression, Action<PropertyConfig<TProp>> configurator) {
|
||||
var prop = Property(propertyExpression);
|
||||
configurator.Invoke(prop);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<string> AddListingProperty(string name, Func<TModel, IServiceProvider, string> template) {
|
||||
var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count);
|
||||
prop.Name = name;
|
||||
prop.IsListingProperty = true;
|
||||
prop.Formatter = (obj, provider) => template.Invoke((TModel)obj, provider);
|
||||
InnerConfig.Properties.Add(prop);
|
||||
return new PropertyConfig<string>(prop);
|
||||
}
|
||||
|
||||
public TableConfig<TModel> AddListingProperty(string name, Func<TModel, IServiceProvider, string> template, Action<PropertyConfig<string>> configurator) {
|
||||
var prop = AddListingProperty(name, template);
|
||||
configurator.Invoke(prop);
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetDisplayName(string name) {
|
||||
InnerConfig.DisplayName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetDescription(string description) {
|
||||
InnerConfig.Description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetOrderIndex(int index) {
|
||||
InnerConfig.Order = index;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetViewPolicy(string policy) {
|
||||
InnerConfig.ViewPolicy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetUpdatePolicy(string policy) {
|
||||
InnerConfig.UpdatePolicy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetCreatePolicy(string policy) {
|
||||
InnerConfig.CreatePolicy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TableConfig<TModel> SetDeletePolicy(string policy) {
|
||||
InnerConfig.DeletePolicy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
internal static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
|
||||
if (propertyLambda.Body is not MemberExpression member) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
|
||||
}
|
||||
|
||||
if (member.Member is not PropertyInfo propInfo) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property.");
|
||||
}
|
||||
|
||||
Type type = typeof(TSource);
|
||||
if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
|
||||
!type.IsSubclassOf(propInfo.ReflectedType)) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");
|
||||
}
|
||||
|
||||
if (propInfo.Name is null)
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property.");
|
||||
|
||||
return propInfo;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
13
src/HopFrame.Core/HopFrame.Core.csproj
Normal file
13
src/HopFrame.Core/HopFrame.Core.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
16
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal file
16
src/HopFrame.Core/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using HopFrame.Core.Services;
|
||||
using HopFrame.Core.Services.Implementations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace HopFrame.Core;
|
||||
|
||||
public static class ServiceCollectionExtensions {
|
||||
|
||||
public static IServiceCollection AddHopFrameServices(this IServiceCollection services) {
|
||||
services.AddTransient<IContextExplorer, ContextExplorer>();
|
||||
services.TryAddTransient<IHopFrameAuthHandler, DefaultAuthHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
10
src/HopFrame.Core/Services/IContextExplorer.cs
Normal file
10
src/HopFrame.Core/Services/IContextExplorer.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Core.Services;
|
||||
|
||||
public interface IContextExplorer {
|
||||
public IEnumerable<TableConfig> GetTables();
|
||||
public TableConfig? GetTable(string tableDisplayName);
|
||||
public TableConfig? GetTable(Type tableEntity);
|
||||
public ITableManager? GetTableManager(string tablePropertyName);
|
||||
}
|
||||
6
src/HopFrame.Core/Services/IHopFrameAuthHandler.cs
Normal file
6
src/HopFrame.Core/Services/IHopFrameAuthHandler.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace HopFrame.Core.Services;
|
||||
|
||||
public interface IHopFrameAuthHandler {
|
||||
public Task<bool> IsAuthenticatedAsync(string? policy);
|
||||
public Task<string> GetCurrentUserDisplayNameAsync();
|
||||
}
|
||||
16
src/HopFrame.Core/Services/ITableManager.cs
Normal file
16
src/HopFrame.Core/Services/ITableManager.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Reflection;
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Core.Services;
|
||||
|
||||
public interface ITableManager {
|
||||
public IQueryable<object> LoadPage(int page, int perPage = 20);
|
||||
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20);
|
||||
public Task<int> TotalPages(int perPage = 20);
|
||||
public Task DeleteItem(object item);
|
||||
public Task EditItem(object item);
|
||||
public Task AddItem(object item);
|
||||
public Task RevertChanges(object item);
|
||||
|
||||
public string DisplayProperty(object? item, PropertyConfig prop, object? value = null);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using HopFrame.Core.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider provider, ILogger<ContextExplorer> logger) : IContextExplorer {
|
||||
public IEnumerable<TableConfig> GetTables() {
|
||||
foreach (var context in config.Contexts) {
|
||||
foreach (var table in context.Tables) {
|
||||
if (table.Ignored) continue;
|
||||
yield return table;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public TableConfig? GetTable(string tableDisplayName) {
|
||||
foreach (var context in config.Contexts) {
|
||||
var table = context.Tables.FirstOrDefault(table => table.DisplayName.Equals(tableDisplayName, StringComparison.CurrentCultureIgnoreCase));
|
||||
if (table is null) continue;
|
||||
|
||||
SeedTableData(table);
|
||||
return table;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public TableConfig? GetTable(Type tableEntity) {
|
||||
foreach (var context in config.Contexts) {
|
||||
var table = context.Tables.FirstOrDefault(table => table.TableType == tableEntity);
|
||||
if (table is null) continue;
|
||||
|
||||
SeedTableData(table);
|
||||
return table;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ITableManager? GetTableManager(string tablePropertyName) {
|
||||
foreach (var context in config.Contexts) {
|
||||
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName);
|
||||
if (table is null) continue;
|
||||
|
||||
var dbContext = provider.GetService(context.ContextType) as DbContext;
|
||||
if (dbContext is null) return null;
|
||||
|
||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||
return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SeedTableData(TableConfig table) {
|
||||
if (table.Seeded) return;
|
||||
var dbContext = (provider.GetService(table.ContextConfig.ContextType) as DbContext)!;
|
||||
var entity = dbContext.Model.FindEntityType(table.TableType)!;
|
||||
|
||||
foreach (var propertyConfig in table.Properties) {
|
||||
if (propertyConfig.IsListingProperty) continue;
|
||||
var prop = entity.FindProperty(propertyConfig.Info.Name);
|
||||
if (prop is not null) continue;
|
||||
var nav = entity.FindNavigation(propertyConfig.Info.Name);
|
||||
if (nav is null) continue;
|
||||
propertyConfig.IsRelation = true;
|
||||
propertyConfig.IsRequired = nav.ForeignKey.IsRequired;
|
||||
propertyConfig.IsEnumerable = nav.IsCollection;
|
||||
}
|
||||
|
||||
foreach (var property in entity.GetProperties()) {
|
||||
var propConfig = table.Properties
|
||||
.Where(prop => !prop.IsListingProperty)
|
||||
.SingleOrDefault(prop => prop.Info == property.PropertyInfo);
|
||||
if (propConfig is null) continue;
|
||||
propConfig.IsRequired = !property.IsNullable;
|
||||
}
|
||||
|
||||
logger.LogInformation("Extracted information for table '" + table.PropertyName + "'");
|
||||
table.Seeded = true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class DefaultAuthHandler : IHopFrameAuthHandler {
|
||||
public Task<bool> IsAuthenticatedAsync(string? policy) {
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
public Task<string> GetCurrentUserDisplayNameAsync() {
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
}
|
||||
121
src/HopFrame.Core/Services/Implementations/TableManager.cs
Normal file
121
src/HopFrame.Core/Services/Implementations/TableManager.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.Collections;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using HopFrame.Core.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class TableManager<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
|
||||
|
||||
public IQueryable<object> LoadPage(int page, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
var data = IncludeForgeinKeys(table);
|
||||
return data
|
||||
.Skip(page * perPage)
|
||||
.Take(perPage);
|
||||
}
|
||||
|
||||
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
var all = IncludeForgeinKeys(table)
|
||||
.AsEnumerable()
|
||||
.Where(item => ItemSearched(item, searchTerm))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult((
|
||||
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage),
|
||||
(int)Math.Ceiling(all.Count / (double)perPage)));
|
||||
}
|
||||
|
||||
public async Task<int> TotalPages(int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
return (int)Math.Ceiling(await table.CountAsync() / (double)perPage);
|
||||
}
|
||||
|
||||
public async Task DeleteItem(object item) {
|
||||
var table = context.Set<TModel>();
|
||||
table.Remove((item as TModel)!);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task EditItem(object item) {
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task AddItem(object item) {
|
||||
var table = context.Set<TModel>();
|
||||
await table.AddAsync((TModel)item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RevertChanges(object item) {
|
||||
await context.Entry((TModel)item).ReloadAsync();
|
||||
}
|
||||
|
||||
private bool ItemSearched(TModel item, string searchTerm) {
|
||||
foreach (var property in config.Properties) {
|
||||
if (!property.Searchable) continue;
|
||||
var value = property.Info.GetValue(item);
|
||||
if (value is null) continue;
|
||||
|
||||
var strValue = value.ToString();
|
||||
if (strValue?.Contains(searchTerm) == true)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public string DisplayProperty(object? item, PropertyConfig prop, object? value = null) {
|
||||
if (item is null) return string.Empty;
|
||||
|
||||
if (prop.IsListingProperty)
|
||||
return prop.Formatter!.Invoke(item, provider);
|
||||
|
||||
var propValue = value ?? prop.Info.GetValue(item);
|
||||
if (propValue is null)
|
||||
return string.Empty;
|
||||
|
||||
if (prop.Formatter is not null) {
|
||||
return prop.Formatter.Invoke(propValue, provider);
|
||||
}
|
||||
|
||||
if (prop.IsEnumerable) {
|
||||
if (value is not null) {
|
||||
if (prop.EnumerableFormatter is not null) {
|
||||
return prop.EnumerableFormatter.Invoke(value, provider);
|
||||
}
|
||||
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return (propValue as IEnumerable)!.OfType<object>().Count().ToString();
|
||||
}
|
||||
|
||||
if (prop.DisplayedProperty is null) {
|
||||
var key = prop.Info.PropertyType
|
||||
.GetProperties()
|
||||
.FirstOrDefault(p => p.GetCustomAttributes(true).Any(a => a is KeyAttribute));
|
||||
|
||||
return key?.GetValue(propValue)?.ToString() ?? propValue.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
var innerConfig = explorer.GetTable(propValue.GetType());
|
||||
var innerProp = innerConfig!.Properties
|
||||
.SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsListingProperty);
|
||||
|
||||
if (innerProp is null) return propValue.ToString() ?? string.Empty;
|
||||
return DisplayProperty(propValue, innerProp);
|
||||
}
|
||||
|
||||
private IQueryable<TModel> IncludeForgeinKeys(IQueryable<TModel> query) {
|
||||
var pendingQuery = query;
|
||||
|
||||
foreach (var property in config.Properties.Where(prop => prop.IsRelation)) {
|
||||
pendingQuery = pendingQuery.Include(property.Info.Name);
|
||||
}
|
||||
|
||||
return pendingQuery;
|
||||
}
|
||||
|
||||
}
|
||||
376
src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor
Normal file
376
src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor
Normal file
@@ -0,0 +1,376 @@
|
||||
@implements IDialogContentComponent<EditorDialogData>
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using System.Collections
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using HopFrame.Web.Models
|
||||
@using HopFrame.Web.Helpers
|
||||
|
||||
<FluentDialogBody>
|
||||
@foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) {
|
||||
if (!_currentlyEditing && !property.Creatable) continue;
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
@if (property.IsRelation) {
|
||||
<div style="display: flex; gap: 5px; align-items: flex-end">
|
||||
<div style="flex-grow: 1">
|
||||
@if (property.IsEnumerable) {
|
||||
<FluentLabel Style="margin-bottom: calc(var(--design-unit) * 1px)">@property.Name</FluentLabel>
|
||||
<div class="hopframe-listview">
|
||||
<FluentOverflow Style="width: 100%">
|
||||
@foreach (var item in GetPropertyValue<IEnumerable>(property) ?? Enumerable.Empty<object>()) {
|
||||
<FluentOverflowItem><FluentBadge>@(GetPropertyValue<string>(property, item))</FluentBadge></FluentOverflowItem>
|
||||
}
|
||||
</FluentOverflow>
|
||||
</div>
|
||||
}
|
||||
else {
|
||||
<FluentTextField
|
||||
Label="@property.Name"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Required="@property.IsRequired"
|
||||
ReadOnly="true"
|
||||
Style="width: 100%" />
|
||||
}
|
||||
</div>
|
||||
<div style="display: flex; gap: 5px; margin-bottom: 4px">
|
||||
@if (!property.IsRequired) {
|
||||
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Relation)" Disabled="@(_currentlyEditing && !property.Editable)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
}
|
||||
<FluentButton OnClick="async () => await OpenRelationalPicker(property)" Disabled="@(_currentlyEditing && !property.Editable)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (property.Info.PropertyType.IsNumeric()) {
|
||||
<FluentNumberField
|
||||
TValue="double"
|
||||
Label="@property.Name"
|
||||
Value="GetPropertyValue<double>(property)"
|
||||
Style="width: 100%;"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Number))" />
|
||||
}
|
||||
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.Boolean) {
|
||||
<FluentSwitch
|
||||
Label="@property.Name"
|
||||
Value="GetPropertyValue<bool>(property)"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Switch))" />
|
||||
}
|
||||
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.DateTime) {
|
||||
<div style="display: flex; gap: 5px">
|
||||
<div style="display: flex; flex-direction: column; width: 100%">
|
||||
<FluentDatePicker
|
||||
Label="@property.Name"
|
||||
Value="GetPropertyValue<DateTime>(property)"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; justify-content: flex-end">
|
||||
<FluentTimePicker
|
||||
Value="GetPropertyValue<DateTime>(property)"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (property.Info.PropertyType == typeof(DateOnly)) {
|
||||
<FluentDatePicker
|
||||
Label="@property.Name"
|
||||
Value="GetPropertyValue<DateOnly>(property).ToDateTime(TimeOnly.MinValue)"
|
||||
Style="width: 100%"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
|
||||
}
|
||||
else if (property.Info.PropertyType == typeof(TimeOnly)) {
|
||||
<FluentTimePicker
|
||||
Label="@property.Name"
|
||||
Value="GetPropertyValue<TimeOnly>(property).ToDateTime()"
|
||||
Style="width: 100%"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
|
||||
}
|
||||
else if (property.Info.PropertyType.IsEnum) {
|
||||
<FluentSelect
|
||||
TOption="string"
|
||||
Label="@property.Name"
|
||||
Items="Enum.GetNames(property.Info.PropertyType)"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Style="width: 100%"
|
||||
Height="250px"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
|
||||
}
|
||||
else if (property.Info.PropertyType.IsNullableEnum()) {
|
||||
<div style="display: flex; gap: 5px; align-items: flex-end">
|
||||
<div style="flex-grow: 1">
|
||||
<FluentSelect
|
||||
TOption="string"
|
||||
Label="@property.Name"
|
||||
Items="@(["", ..Enum.GetNames(Nullable.GetUnderlyingType(property.Info.PropertyType)!)])"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Style="width: 100%"
|
||||
Height="250px"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
|
||||
</div>
|
||||
<div style="display: flex; gap: 5px">
|
||||
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Enum)" Disabled="@(_currentlyEditing && !property.Editable)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (property.TextArea) {
|
||||
<FluentTextArea
|
||||
Label="@property.Name"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Style="width: 100%;"
|
||||
Rows="@property.TextAreaRows"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
|
||||
}
|
||||
else {
|
||||
<FluentTextField
|
||||
Label="@property.Name"
|
||||
Value="@(GetPropertyValue<string>(property))"
|
||||
Style="width: 100%;"
|
||||
Disabled="@(_currentlyEditing && !property.Editable)"
|
||||
Required="@property.IsRequired"
|
||||
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
|
||||
}
|
||||
|
||||
@foreach (var error in _validationErrors[property.Info.Name]) {
|
||||
<FluentLabel Color="@Color.Error">@error</FluentLabel>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</FluentDialogBody>
|
||||
|
||||
<FluentToastProvider MaxToastCount="10" />
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject IDialogService Dialogs
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
@inject IToastService Toasts
|
||||
@inject IServiceProvider Provider
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required EditorDialogData Content { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public required FluentDialog Dialog { get; set; }
|
||||
|
||||
private bool _currentlyEditing;
|
||||
private ITableManager? _manager;
|
||||
private readonly Dictionary<string, List<string>> _validationErrors = new();
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_currentlyEditing = Content.CurrentObject is not null;
|
||||
Dialog.Instance.Parameters.Title = (_currentlyEditing ? "Edit " : "Add ") + Content.Config.TableType.Name;
|
||||
Dialog.Instance.Parameters.Width = "500px";
|
||||
Dialog.Instance.Parameters.PrimaryAction = "Save";
|
||||
Dialog.Instance.Parameters.ValidateDialogAsync = ValidateInputs;
|
||||
_manager = Explorer.GetTableManager(Content.Config.PropertyName);
|
||||
Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType);
|
||||
|
||||
foreach (var property in Content.Config.Properties) {
|
||||
if (property.IsListingProperty) continue;
|
||||
_validationErrors.Add(property.Info.Name, []);
|
||||
}
|
||||
}
|
||||
|
||||
private TValue? GetPropertyValue<TValue>(PropertyConfig config, object? listItem = null) {
|
||||
if (!config.DisplayValue) return default;
|
||||
if (Content.CurrentObject is null) return default;
|
||||
|
||||
if (listItem is not null) {
|
||||
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, listItem);
|
||||
}
|
||||
|
||||
var value = config.Info.GetValue(Content.CurrentObject);
|
||||
|
||||
if (value is null)
|
||||
return default;
|
||||
|
||||
if (typeof(TValue).IsAssignableFrom(config.Info.PropertyType))
|
||||
return (TValue)value;
|
||||
|
||||
if (typeof(TValue) == typeof(string))
|
||||
return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config);
|
||||
|
||||
return (TValue)Convert.ChangeType(value, typeof(TValue));
|
||||
}
|
||||
|
||||
private async Task SetPropertyValue(PropertyConfig config, object? value, InputType senderType) {
|
||||
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
|
||||
return;
|
||||
|
||||
object? result = null;
|
||||
var needsOverride = true;
|
||||
|
||||
if (value is not null && config.Parser is null) {
|
||||
switch (senderType) {
|
||||
case InputType.Number:
|
||||
result = Convert.ChangeType(value, config.Info.PropertyType);
|
||||
break;
|
||||
|
||||
case InputType.Text:
|
||||
if (config.Info.PropertyType == typeof(Guid)) {
|
||||
var success = Guid.TryParse((string)value, out var guid);
|
||||
if (success) result = guid;
|
||||
else Toasts.ShowError($"'{value}' is not a valid guid");
|
||||
break;
|
||||
}
|
||||
|
||||
result = Convert.ToString(value);
|
||||
break;
|
||||
|
||||
case InputType.Switch:
|
||||
result = Convert.ToBoolean(value);
|
||||
break;
|
||||
|
||||
case InputType.Enum:
|
||||
var type = Nullable.GetUnderlyingType(config.Info.PropertyType);
|
||||
if (type is not null && string.IsNullOrEmpty((string)value)) break;
|
||||
type ??= config.Info.PropertyType;
|
||||
result = Enum.Parse(type, (string)value);
|
||||
break;
|
||||
|
||||
case InputType.Date:
|
||||
if (config.Info.PropertyType == typeof(DateTime)) {
|
||||
var newDate = (DateTime)value;
|
||||
var dateTime = GetPropertyValue<DateTime>(config);
|
||||
result = new DateTime(newDate.Year, newDate.Month, newDate.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, dateTime.Microsecond);
|
||||
}
|
||||
else result = DateOnly.FromDateTime((DateTime)value);
|
||||
break;
|
||||
|
||||
case InputType.Time:
|
||||
if (config.Info.PropertyType == typeof(DateTime)) {
|
||||
var newTime = (DateTime)value;
|
||||
var dateTime = GetPropertyValue<DateTime>(config);
|
||||
result = new DateTime(dateTime.Year, dateTime.Month, dateTime.Day, newTime.Hour, newTime.Minute, newTime.Second, newTime.Millisecond, newTime.Microsecond);
|
||||
}
|
||||
else result = TimeOnly.FromDateTime((DateTime)value);
|
||||
break;
|
||||
|
||||
case InputType.Relation:
|
||||
if (!config.IsEnumerable)
|
||||
result = ((IEnumerable)value).OfType<object>().FirstOrDefault();
|
||||
else {
|
||||
needsOverride = false;
|
||||
|
||||
if (!typeof(IList).IsAssignableFrom(config.Info.PropertyType)) {
|
||||
throw new ArgumentException($"Invalid type of '{config.Name}' property in '{config.Table.DisplayName}' table, only list types are supported on enumerable relations.");
|
||||
}
|
||||
|
||||
var asList = (IList)config.Info.GetValue(Content.CurrentObject)!;
|
||||
asList.Clear();
|
||||
foreach (var element in (IEnumerable)value) {
|
||||
asList.Add(element);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(senderType), senderType, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.Parser is not null && result is not null) {
|
||||
result = config.Parser(result.ToString()!, Provider);
|
||||
}
|
||||
|
||||
if (needsOverride)
|
||||
config.Info.SetValue(Content.CurrentObject, result);
|
||||
}
|
||||
|
||||
private async Task OpenRelationalPicker(PropertyConfig config) {
|
||||
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
|
||||
return;
|
||||
|
||||
var relationType = config.Info.PropertyType;
|
||||
if (config.IsEnumerable) {
|
||||
relationType = config.Info.PropertyType.GetGenericArguments().First();
|
||||
}
|
||||
|
||||
var relationTable = Explorer.GetTable(relationType);
|
||||
if (relationTable is null) return;
|
||||
|
||||
var currentValues = new List<object>();
|
||||
if (config.IsEnumerable) {
|
||||
foreach (var o in GetPropertyValue<IEnumerable>(config) ?? Enumerable.Empty<object>()) {
|
||||
currentValues.Add(o);
|
||||
}
|
||||
}
|
||||
else {
|
||||
var raw = config.Info.GetValue(Content.CurrentObject);
|
||||
if (raw is not null)
|
||||
currentValues.Add(raw);
|
||||
}
|
||||
|
||||
var dialog = await Dialogs.ShowDialogAsync<HopFrameRelationPicker>(new RelationPickerDialogData(relationTable, currentValues, config.IsEnumerable), new DialogParameters());
|
||||
var result = await dialog.Result;
|
||||
if (result.Cancelled) return;
|
||||
|
||||
var data = (RelationPickerDialogData)result.Data!;
|
||||
await SetPropertyValue(config, data.SelectedObjects, InputType.Relation);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateInputs() {
|
||||
if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy))
|
||||
return false;
|
||||
|
||||
foreach (var property in Content.Config.Properties) {
|
||||
if (property.IsListingProperty) continue;
|
||||
|
||||
var errorList = _validationErrors[property.Info.Name];
|
||||
errorList.Clear();
|
||||
var value = property.Info.GetValue(Content.CurrentObject);
|
||||
|
||||
if (property.Validator is not null) {
|
||||
errorList.AddRange(await property.Validator.Invoke(value, Provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value is null && property.IsRequired)
|
||||
errorList.Add($"{property.Name} is required");
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
var valid = _validationErrors
|
||||
.Select(err => err.Value.Count)
|
||||
.All(c => c == 0);
|
||||
|
||||
if (!valid) return false;
|
||||
var dialog = await Dialogs.ShowConfirmationAsync($"Do you really want to {(_currentlyEditing ? "edit" : "create")} this entry?");
|
||||
var result = await dialog.Result;
|
||||
return !result.Cancelled;
|
||||
}
|
||||
|
||||
private enum InputType {
|
||||
Number,
|
||||
Switch,
|
||||
Date,
|
||||
Time,
|
||||
Enum,
|
||||
Text,
|
||||
Relation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@implements IDialogContentComponent<RelationPickerDialogData>
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using HopFrame.Web.Models
|
||||
@using HopFrame.Web.Components.Pages
|
||||
|
||||
<FluentDialogBody Style="overflow-x: auto">
|
||||
<HopFrameTablePage
|
||||
DisplayActions="false"
|
||||
DisplaySelection="true"
|
||||
TableDisplayName="@Content.SourceTable.DisplayName"
|
||||
PerPage="15"
|
||||
DialogData="Content"
|
||||
SelectionMode="@(Content.AllowMultiple ? DataGridSelectMode.Multiple : DataGridSelectMode.Single)"/>
|
||||
</FluentDialogBody>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required RelationPickerDialogData Content { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public required FluentDialog Dialog { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
Dialog.Instance.Parameters.Title = $"Select {Content.SourceTable.TableType.Name}";
|
||||
Dialog.Instance.Parameters.Width = "90vw";
|
||||
Dialog.Instance.Parameters.Height = "90vh";
|
||||
Dialog.Instance.Parameters.PrimaryAction = "Assign";
|
||||
}
|
||||
|
||||
}
|
||||
46
src/HopFrame.Web/Components/Layout/HopFrameLayout.razor
Normal file
46
src/HopFrame.Web/Components/Layout/HopFrameLayout.razor
Normal file
@@ -0,0 +1,46 @@
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<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"/>
|
||||
|
||||
<FluentDesignTheme Mode="DesignThemeModes.Dark" />
|
||||
|
||||
<FluentLayout Class="hopframe-outer">
|
||||
<HopFrameNavigation />
|
||||
<FluentStack Orientation="Orientation.Horizontal" Width="100%" Class="hopframe-main">
|
||||
<HopFrameSideMenu />
|
||||
<FluentBodyContent>
|
||||
<div class="hopframe-content">
|
||||
@Body
|
||||
</div>
|
||||
</FluentBodyContent>
|
||||
</FluentStack>
|
||||
<FluentFooter>
|
||||
<a href="https://git.leon-hoppe.de/leon.hoppe/hopframe" target="_blank">Documentation and source code</a>
|
||||
<FluentSpacer/>
|
||||
<a href="https://git.leon-hoppe.de/leon.hoppe/" target="_blank" style="margin-right: 0.25rem; text-decoration: none">
|
||||
<i class="devicon-gitlab-plain"></i>
|
||||
</a>
|
||||
<a href="https://github.com/leonhoppe" target="_blank" style="text-decoration: none">
|
||||
<i class="devicon-github-original"></i>
|
||||
</a>
|
||||
</FluentFooter>
|
||||
</FluentLayout>
|
||||
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
@inject HopFrameConfig Config
|
||||
@inject NavigationManager Navigator
|
||||
|
||||
@code {
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
var authorized = await Handler.IsAuthenticatedAsync(Config.BasePolicy);
|
||||
if (!authorized) {
|
||||
Navigator.NavigateTo((Config.LoginPageRewrite ?? "/login") + "?redirect=/" + Navigator.ToBaseRelativePath(Navigator.Uri), true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
45
src/HopFrame.Web/Components/Layout/HopFrameNavigation.razor
Normal file
45
src/HopFrame.Web/Components/Layout/HopFrameNavigation.razor
Normal file
@@ -0,0 +1,45 @@
|
||||
@using System.Text
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
|
||||
<FluentHeader Class="hopframe-header">
|
||||
<a href="/admin" style="text-decoration: none; color: @Color.Neutral.ToAttributeValue();">HopFrame</a>
|
||||
|
||||
@if (Config.DisplayUserInfo) {
|
||||
<FluentPersona Name="@_displayName" Initials="@_initials" ImageSize="32px" TextPosition="TextPosition.Start" Style="margin-left: auto"/>
|
||||
}
|
||||
|
||||
</FluentHeader>
|
||||
|
||||
@inject HopFrameConfig Config
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
|
||||
@code {
|
||||
|
||||
private string? _displayName;
|
||||
private string? _initials;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
if (Config.DisplayUserInfo) {
|
||||
_displayName = await Handler.GetCurrentUserDisplayNameAsync();
|
||||
_initials = GetInitials(_displayName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInitials(string input) {
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return string.Empty;
|
||||
|
||||
StringBuilder initials = new StringBuilder();
|
||||
string[] words = input.Split([' ', '.', '_'], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (string word in words) {
|
||||
if (!string.IsNullOrEmpty(word) && char.IsLetter(word[0]))
|
||||
initials.Append(word[0]);
|
||||
}
|
||||
|
||||
return initials.ToString().ToUpper();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
39
src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor
Normal file
39
src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor
Normal file
@@ -0,0 +1,39 @@
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
|
||||
<FluentAppBar Orientation="Orientation.Vertical" PopoverShowSearch="false" Style="background-color: var(--neutral-layer-2); height: auto">
|
||||
<FluentAppBarItem Href="/admin"
|
||||
Match="NavLinkMatch.All"
|
||||
IconActive="new Icons.Filled.Size24.Grid()"
|
||||
IconRest="new Icons.Regular.Size24.Grid()"
|
||||
Text="Dashboard"
|
||||
Style="margin-top: 0.25rem"/>
|
||||
|
||||
<br>
|
||||
|
||||
@foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) {
|
||||
<FluentAppBarItem Href="@("/admin/" + table.ToLower())"
|
||||
Match="NavLinkMatch.All"
|
||||
IconActive="new Icons.Filled.Size24.Database()"
|
||||
IconRest="new Icons.Regular.Size24.Database()"
|
||||
Text="@table"
|
||||
Style="margin-top: 0.25rem"/>
|
||||
}
|
||||
</FluentAppBar>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
|
||||
@code {
|
||||
|
||||
private readonly List<TableConfig> _tables = [];
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
foreach (var table in Explorer.GetTables()) {
|
||||
if (table.Ignored) continue;
|
||||
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
|
||||
_tables.Add(table);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
45
src/HopFrame.Web/Components/Pages/HopFrameHome.razor
Normal file
45
src/HopFrame.Web/Components/Pages/HopFrameHome.razor
Normal file
@@ -0,0 +1,45 @@
|
||||
@page "/admin"
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@layout HopFrameLayout
|
||||
|
||||
<PageTitle>HopFrame</PageTitle>
|
||||
|
||||
<div style="padding: 1.5rem 1.5rem;">
|
||||
<h2>Tables</h2>
|
||||
|
||||
<FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem">
|
||||
@foreach (var table in _tables.OrderBy(t => t.Order)) {
|
||||
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)">
|
||||
<h3 style="margin-bottom: 0;">@table.DisplayName</h3>
|
||||
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@table.ViewPolicy</FluentLabel>
|
||||
<span>@table.Description</span>
|
||||
<FluentSpacer />
|
||||
<div style="display: flex">
|
||||
<FluentSpacer/>
|
||||
|
||||
<a href="@("/admin/" + table.DisplayName.ToLower())" style="display: inline-block">
|
||||
<FluentButton>Open</FluentButton>
|
||||
</a>
|
||||
</div>
|
||||
</FluentCard>
|
||||
}
|
||||
</FluentStack>
|
||||
</div>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
|
||||
@code {
|
||||
|
||||
private readonly List<TableConfig> _tables = [];
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
foreach (var table in Explorer.GetTables()) {
|
||||
if (table.Ignored) continue;
|
||||
if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue;
|
||||
_tables.Add(table);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
286
src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
Normal file
286
src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
Normal file
@@ -0,0 +1,286 @@
|
||||
@page "/admin/{TableDisplayName}"
|
||||
@layout HopFrameLayout
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using HopFrame.Web.Models
|
||||
@using Microsoft.JSInterop
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
@if (!DisplaySelection) {
|
||||
<PageTitle>@_config?.DisplayName</PageTitle>
|
||||
}
|
||||
|
||||
<FluentDialogProvider />
|
||||
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<FluentToolbar Class="hopframe-toolbar">
|
||||
<h3>@_config?.DisplayName</h3>
|
||||
@if (!DisplaySelection) {
|
||||
<FluentButton
|
||||
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
|
||||
OnClick="Reload"
|
||||
Loading="_loading"
|
||||
Style="margin-left: 10px">
|
||||
Refresh
|
||||
</FluentButton>
|
||||
}
|
||||
|
||||
<FluentSpacer />
|
||||
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" />
|
||||
|
||||
@if (_hasCreatePolicy && DisplayActions) {
|
||||
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
|
||||
}
|
||||
</FluentToolbar>
|
||||
<FluentProgress Visible="_loading" Width="100%" />
|
||||
|
||||
<div style="display: flex; overflow-y: auto; flex-grow: 1">
|
||||
<div style="flex-grow: 1">
|
||||
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()">
|
||||
@if (DisplaySelection) {
|
||||
<SelectColumn
|
||||
TGridItem="object"
|
||||
SelectMode="SelectionMode"
|
||||
SelectFromEntireRow="true"
|
||||
SelectedItems="DialogData?.SelectedObjects.ToArray()"
|
||||
OnSelect="data => SelectItem(data.Item, data.Selected)"
|
||||
SelectAllChanged="SelectAll"
|
||||
SelectAll="_allSelected"
|
||||
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)) {
|
||||
<PropertyColumn
|
||||
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property, null)"
|
||||
Style="min-width: max-content; height: 44px;"
|
||||
Sortable="@property.Sortable"/>
|
||||
}
|
||||
|
||||
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
|
||||
var dataIndex = 0;
|
||||
|
||||
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
|
||||
@{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); }
|
||||
|
||||
@if (_hasUpdatePolicy) {
|
||||
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(currentElement); }">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
|
||||
</FluentButton>
|
||||
}
|
||||
|
||||
@if (_hasDeletePolicy) {
|
||||
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
|
||||
</FluentButton>
|
||||
}
|
||||
|
||||
@{
|
||||
dataIndex++;
|
||||
dataIndex %= 20;
|
||||
}
|
||||
</TemplateColumn>
|
||||
}
|
||||
</FluentDataGrid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_totalPages > 1) {
|
||||
<div class="hopframe-paginator">
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
|
||||
<span>Page</span>
|
||||
|
||||
<FluentSelect TOption="int"
|
||||
Items="Enumerable.Range(0, _totalPages)"
|
||||
OptionValue="@(p => p.ToString())"
|
||||
OptionText="@(p => (p + 1).ToString())"
|
||||
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))"
|
||||
Width="max-content" SelectedOption="@_currentPage"/>
|
||||
|
||||
<span>of @_totalPages</span>
|
||||
|
||||
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function removeBg() {
|
||||
const elements = document.querySelectorAll(".col-sort-button");
|
||||
const style = new CSSStyleSheet();
|
||||
style.replaceSync(".control { background: none !important; }");
|
||||
elements.forEach(e => e?.shadowRoot?.adoptedStyleSheets?.push(style));
|
||||
}
|
||||
|
||||
removeBg();
|
||||
</script>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject NavigationManager Navigator
|
||||
@inject IJSRuntime Js
|
||||
@inject IDialogService Dialogs
|
||||
@inject IHopFrameAuthHandler Handler
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required string TableDisplayName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool DisplaySelection { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool DisplayActions { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public RelationPickerDialogData? DialogData { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public DataGridSelectMode SelectionMode { get; set; } = DataGridSelectMode.Single;
|
||||
|
||||
[Parameter]
|
||||
public int PerPage { get; set; } = 20;
|
||||
|
||||
private TableConfig? _config;
|
||||
private ITableManager? _manager;
|
||||
|
||||
private object[] _currentlyDisplayedModels = [];
|
||||
private int _currentPage;
|
||||
private int _totalPages;
|
||||
private string? _searchTerm;
|
||||
private bool _loading;
|
||||
|
||||
private bool _hasUpdatePolicy;
|
||||
private bool _hasDeletePolicy;
|
||||
private bool _hasCreatePolicy;
|
||||
|
||||
private SelectColumn<object>? _selectColumn;
|
||||
private bool _allSelected;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_config ??= Explorer.GetTable(TableDisplayName);
|
||||
|
||||
if (_config is null || (_config.Ignored && DialogData is null)) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
if (!await Handler.IsAuthenticatedAsync(_config?.ViewPolicy)) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
return;
|
||||
}
|
||||
|
||||
_hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy);
|
||||
_hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy);
|
||||
_hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy);
|
||||
|
||||
_manager ??= Explorer.GetTableManager(_config!.PropertyName);
|
||||
_currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync();
|
||||
_totalPages = await _manager.TotalPages(PerPage);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
try {
|
||||
await Js.InvokeVoidAsync("removeBg");
|
||||
}
|
||||
catch (Exception) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_searchCancel.Dispose();
|
||||
}
|
||||
|
||||
private CancellationTokenSource _searchCancel = new();
|
||||
private async Task OnSearch(ChangeEventArgs eventArgs) {
|
||||
await _searchCancel.CancelAsync();
|
||||
_searchTerm = eventArgs.Value?.ToString();
|
||||
if (_searchTerm is null) return;
|
||||
_searchCancel = new();
|
||||
|
||||
await Task.Delay(500, _searchCancel.Token);
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload() {
|
||||
_loading = true;
|
||||
if (!string.IsNullOrEmpty(_searchTerm)) {
|
||||
(var query, _totalPages) = await _manager!.Search(_searchTerm, 0, PerPage);
|
||||
_currentlyDisplayedModels = query.ToArray();
|
||||
}
|
||||
else {
|
||||
await OnInitializedAsync();
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task ChangePage(int page) {
|
||||
if (page < 0 || page > _totalPages - 1) return;
|
||||
_currentPage = page;
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task DeleteEntry(object element) {
|
||||
if (!await Handler.IsAuthenticatedAsync(_config?.DeletePolicy)) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
|
||||
var result = await dialog.Result;
|
||||
if (result.Cancelled) return;
|
||||
|
||||
await _manager!.DeleteItem(element);
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task CreateOrEdit(object? element) {
|
||||
if (!await Handler.IsAuthenticatedAsync(element is null ? _config?.CreatePolicy : _config?.UpdatePolicy)) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
|
||||
TrapFocus = false
|
||||
});
|
||||
var result = await panel.Result;
|
||||
var data = result.Data as EditorDialogData;
|
||||
|
||||
if (result.Cancelled) {
|
||||
if (data?.CurrentObject is not null)
|
||||
await _manager!.RevertChanges(data.CurrentObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element is null)
|
||||
await _manager!.AddItem(data!.CurrentObject!);
|
||||
else
|
||||
await _manager!.EditItem(data!.CurrentObject!);
|
||||
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private void SelectItem(object item, bool selected) {
|
||||
if (!selected)
|
||||
DialogData?.SelectedObjects.Remove(item);
|
||||
else DialogData?.SelectedObjects.Add(item);
|
||||
}
|
||||
|
||||
private void SelectAll() {
|
||||
var selected = _currentlyDisplayedModels.Any(obj => DialogData?.SelectedObjects.Contains(obj) != true);
|
||||
foreach (var displayedModel in _currentlyDisplayedModels) {
|
||||
SelectItem(displayedModel, selected);
|
||||
}
|
||||
|
||||
_allSelected = selected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hopframe-paginator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hopframe-radio {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
|
||||
}
|
||||
23
src/HopFrame.Web/Helpers/TypeExtensions.cs
Normal file
23
src/HopFrame.Web/Helpers/TypeExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace HopFrame.Web.Helpers;
|
||||
|
||||
internal static class TypeExtensions {
|
||||
public static bool IsNumeric(this Type o) {
|
||||
if (o.IsEnum) return false;
|
||||
switch (Type.GetTypeCode(o)) {
|
||||
case TypeCode.Byte:
|
||||
case TypeCode.SByte:
|
||||
case TypeCode.UInt16:
|
||||
case TypeCode.UInt32:
|
||||
case TypeCode.UInt64:
|
||||
case TypeCode.Int16:
|
||||
case TypeCode.Int32:
|
||||
case TypeCode.Int64:
|
||||
case TypeCode.Decimal:
|
||||
case TypeCode.Double:
|
||||
case TypeCode.Single:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/HopFrame.Web/HopFrame.Web.csproj
Normal file
24
src/HopFrame.Web/HopFrame.Web.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0"/>
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HopFrame.Core\HopFrame.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
src/HopFrame.Web/Models/EditorDialogData.cs
Normal file
8
src/HopFrame.Web/Models/EditorDialogData.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Web.Models;
|
||||
|
||||
public sealed class EditorDialogData(TableConfig config, object? current = null) {
|
||||
public object? CurrentObject { get; set; } = current;
|
||||
public TableConfig Config { get; } = config;
|
||||
}
|
||||
9
src/HopFrame.Web/Models/RelationPickerDialogData.cs
Normal file
9
src/HopFrame.Web/Models/RelationPickerDialogData.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using HopFrame.Core.Config;
|
||||
|
||||
namespace HopFrame.Web.Models;
|
||||
|
||||
public sealed class RelationPickerDialogData(TableConfig sourceTable, List<object> current, bool multiple) {
|
||||
public List<object> SelectedObjects { get; set; } = current;
|
||||
public TableConfig SourceTable { get; init; } = sourceTable;
|
||||
public bool AllowMultiple { get; set; } = multiple;
|
||||
}
|
||||
23
src/HopFrame.Web/ServiceCollectionExtensions.cs
Normal file
23
src/HopFrame.Web/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using HopFrame.Core;
|
||||
using HopFrame.Core.Config;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.FluentUI.AspNetCore.Components;
|
||||
|
||||
namespace HopFrame.Web;
|
||||
|
||||
public static class ServiceCollectionExtensions {
|
||||
|
||||
public static IServiceCollection AddHopFrame(this IServiceCollection services, Action<HopFrameConfigurator> configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null) {
|
||||
var config = new HopFrameConfig();
|
||||
configurator.Invoke(new HopFrameConfigurator(config));
|
||||
return AddHopFrame(services, config, fluentUiLibraryConfiguration);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config, LibraryConfiguration? fluentUiLibraryConfiguration = null) {
|
||||
services.AddSingleton(config);
|
||||
services.AddHopFrameServices();
|
||||
services.AddFluentUIComponents(fluentUiLibraryConfiguration);
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
12
src/HopFrame.Web/_Imports.razor
Normal file
12
src/HopFrame.Web/_Imports.razor
Normal file
@@ -0,0 +1,12 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons
|
||||
@using Microsoft.FluentUI.AspNetCore.Components.Extensions
|
||||
@using HopFrame.Web.Components.Layout
|
||||
@using HopFrame.Web.Components.Dialogs
|
||||
42
src/HopFrame.Web/wwwroot/hopframe.css
Normal file
42
src/HopFrame.Web/wwwroot/hopframe.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.hopframe-header {
|
||||
background-color: var(--neutral-layer-4) !important;
|
||||
border-bottom: calc(var(--stroke-width) * 2px) solid var(--accent-fill-rest) !important;
|
||||
color: var(--neutral-foreground-rest) !important;
|
||||
}
|
||||
|
||||
.hopframe-content {
|
||||
align-self: stretch !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hopframe-main {
|
||||
min-height: calc(100dvh - 86px);
|
||||
color: var(--neutral-foreground-rest);
|
||||
align-items: stretch !important;
|
||||
column-gap: 0 !important;
|
||||
}
|
||||
|
||||
.hopframe-toolbar {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.hopframe-listview {
|
||||
background: padding-box linear-gradient(var(--neutral-fill-input-rest), var(--neutral-fill-input-rest)), border-box var(--neutral-stroke-input-rest);
|
||||
border: calc(var(--stroke-width) * 1px) solid transparent;
|
||||
border-radius: calc(var(--control-corner-radius) * 1px);
|
||||
padding: 0 calc(var(--design-unit) * 2px + 1px);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.hopframe-content .empty-content-row.empty-content-cell {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
fluent-option {
|
||||
background: transparent !important;
|
||||
}
|
||||
20
testing/HopFrame.Testing/Components/App.razor
Normal file
20
testing/HopFrame.Testing/Components/App.razor
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="@Assets["app.css"]"/>
|
||||
<link rel="stylesheet" href="@Assets["HopFrame.Testing.styles.css"]"/>
|
||||
<ImportMap/>
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
testing/HopFrame.Testing/Components/Layout/MainLayout.razor
Normal file
26
testing/HopFrame.Testing/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,26 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<FluentLayout>
|
||||
<FluentHeader>
|
||||
HopFrame.Testing
|
||||
</FluentHeader>
|
||||
<FluentStack Class="main" Orientation="Orientation.Horizontal" Width="100%">
|
||||
<NavMenu/>
|
||||
<FluentBodyContent Class="body-content">
|
||||
<div class="content">
|
||||
@Body
|
||||
</div>
|
||||
</FluentBodyContent>
|
||||
</FluentStack>
|
||||
<FluentFooter>
|
||||
<a href="https://www.fluentui-blazor.net" target="_blank">Documentation and demos</a>
|
||||
<FluentSpacer/>
|
||||
<a href="https://learn.microsoft.com/en-us/aspnet/core/blazor" target="_blank">About Blazor</a>
|
||||
</FluentFooter>
|
||||
</FluentLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
19
testing/HopFrame.Testing/Components/Layout/NavMenu.razor
Normal file
19
testing/HopFrame.Testing/Components/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,19 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="navmenu">
|
||||
<input type="checkbox" title="Menu expand/collapse toggle" id="navmenu-toggle" class="navmenu-icon"/>
|
||||
<label for="navmenu-toggle" class="navmenu-icon">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.Navigation())" Color="Color.Fill"/>
|
||||
</label>
|
||||
<nav class="sitenav" aria-labelledby="main-menu">
|
||||
<FluentNavMenu Id="main-menu" Collapsible="true" Width="250" Title="Navigation menu" @bind-Expanded="expanded" CustomToggle="true">
|
||||
<FluentNavLink Href="/" Match="NavLinkMatch.All" Icon="@(new Icons.Regular.Size20.Home())" IconColor="Color.Accent">Home</FluentNavLink>
|
||||
<FluentNavLink Href="counter" Icon="@(new Icons.Regular.Size20.NumberSymbolSquare())" IconColor="Color.Accent">Counter</FluentNavLink>
|
||||
<FluentNavLink Href="weather" Icon="@(new Icons.Regular.Size20.WeatherPartlyCloudyDay())" IconColor="Color.Accent">Weather</FluentNavLink>
|
||||
</FluentNavMenu>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool expanded = true;
|
||||
}
|
||||
21
testing/HopFrame.Testing/Components/Pages/Counter.razor
Normal file
21
testing/HopFrame.Testing/Components/Pages/Counter.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<div role="status" style="padding-bottom: 1em;">
|
||||
Current count: <FluentBadge Appearance="Appearance.Neutral">@currentCount</FluentBadge>
|
||||
</div>
|
||||
|
||||
<FluentButton Appearance="Appearance.Accent" @onclick="IncrementCount">Click me</FluentButton>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount() {
|
||||
currentCount++;
|
||||
}
|
||||
|
||||
}
|
||||
35
testing/HopFrame.Testing/Components/Pages/Error.razor
Normal file
35
testing/HopFrame.Testing/Components/Pages/Error.razor
Normal file
@@ -0,0 +1,35 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId) {
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
|
||||
}
|
||||
65
testing/HopFrame.Testing/Components/Pages/Home.razor
Normal file
65
testing/HopFrame.Testing/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,65 @@
|
||||
@page "/"
|
||||
@using HopFrame.Testing.Models
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new Fluent Blazor app.
|
||||
|
||||
@inject DatabaseContext Context
|
||||
|
||||
@code {
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
User? user = null;
|
||||
for (int i = 0; i < 100; i++) {
|
||||
var first = GenerateName(Random.Shared.Next(4, 6));
|
||||
var last = GenerateName(Random.Shared.Next(4, 6));
|
||||
var username = $"{first}.{last}";
|
||||
|
||||
user = new() {
|
||||
Email = $"{username}-{Random.Shared.Next(0, 20)}@gmail.com",
|
||||
Id = Guid.CreateVersion7(),
|
||||
FirstName = first,
|
||||
LastName = last,
|
||||
Username = username,
|
||||
Password = GenerateName(Random.Shared.Next(8, 16))
|
||||
};
|
||||
|
||||
Context.Users.Add(user);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
Context.Posts.Add(new() {
|
||||
Caption = "Cool Post",
|
||||
Content = "This post is cool",
|
||||
Author = user
|
||||
});
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static string GenerateName(int len) {
|
||||
Random r = new Random();
|
||||
string[] consonants = { "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "l", "n", "p", "q", "r", "s", "sh", "zh", "t", "v", "w", "x" };
|
||||
string[] vowels = { "a", "e", "i", "o", "u", "ae", "y" };
|
||||
string Name = "";
|
||||
Name += consonants[r.Next(consonants.Length)].ToUpper();
|
||||
Name += vowels[r.Next(vowels.Length)];
|
||||
int b = 2; //b tells how many times a new letter has been added. It's 2 right now because the first two letters are already in the name.
|
||||
while (b < len) {
|
||||
Name += consonants[r.Next(consonants.Length)];
|
||||
b++;
|
||||
Name += vowels[r.Next(vowels.Length)];
|
||||
b++;
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
|
||||
}
|
||||
using HopFrame.Testing.Models;
|
||||
using System.Runtime.InteropServices;
|
||||
48
testing/HopFrame.Testing/Components/Pages/Weather.razor
Normal file
48
testing/HopFrame.Testing/Components/Pages/Weather.razor
Normal file
@@ -0,0 +1,48 @@
|
||||
@page "/weather"
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
<h1>Weather</h1>
|
||||
|
||||
<p>This component demonstrates showing data.</p>
|
||||
|
||||
@if (forecasts == null) {
|
||||
<p>
|
||||
<em>Loading...</em>
|
||||
</p>
|
||||
}
|
||||
else {
|
||||
<!-- This page is rendered in SSR mode, so the FluentDataGrid component does not offer any interactivity (like sorting). -->
|
||||
<FluentDataGrid Id="weathergrid" Items="@forecasts" GridTemplateColumns="1fr 1fr 1fr 2fr" TGridItem="WeatherForecast">
|
||||
<PropertyColumn Title="Date" Property="@(c => c!.Date)" Align="Align.Start"/>
|
||||
<PropertyColumn Title="Temp. (C)" Property="@(c => c!.TemperatureC)" Align="Align.Center"/>
|
||||
<PropertyColumn Title="Temp. (F)" Property="@(c => c!.TemperatureF)" Align="Align.Center"/>
|
||||
<PropertyColumn Title="Summary" Property="@(c => c!.Summary)" Align="Align.End"/>
|
||||
</FluentDataGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IQueryable<WeatherForecast>? forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
||||
await Task.Delay(500);
|
||||
|
||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast {
|
||||
Date = startDate.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
||||
}).AsQueryable();
|
||||
}
|
||||
|
||||
private class WeatherForecast {
|
||||
public DateOnly Date { get; set; }
|
||||
public int TemperatureC { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
|
||||
}
|
||||
6
testing/HopFrame.Testing/Components/Routes.razor
Normal file
6
testing/HopFrame.Testing/Components/Routes.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
12
testing/HopFrame.Testing/Components/_Imports.razor
Normal file
12
testing/HopFrame.Testing/Components/_Imports.razor
Normal file
@@ -0,0 +1,12 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons
|
||||
@using Microsoft.JSInterop
|
||||
@using HopFrame.Testing
|
||||
@using HopFrame.Testing.Components
|
||||
19
testing/HopFrame.Testing/DatabaseContext.cs
Normal file
19
testing/HopFrame.Testing/DatabaseContext.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using HopFrame.Testing.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Testing;
|
||||
|
||||
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
public DbSet<Post> Posts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Post>()
|
||||
.HasOne<User>(p => p.Author)
|
||||
.WithMany(u => u.Posts)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
18
testing/HopFrame.Testing/HopFrame.Testing.csproj
Normal file
18
testing/HopFrame.Testing/HopFrame.Testing.csproj
Normal 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.EntityFrameworkCore.InMemory" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
|
||||
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\HopFrame.Web\HopFrame.Web.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
testing/HopFrame.Testing/Models/Post.cs
Normal file
30
testing/HopFrame.Testing/Models/Post.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HopFrame.Testing.Models;
|
||||
|
||||
public class Post {
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public int Id { get; set; }
|
||||
|
||||
[MaxLength(255)]
|
||||
public required string Caption { get; set; }
|
||||
|
||||
public required string? Content { get; set; }
|
||||
|
||||
[ForeignKey("author")]
|
||||
public virtual required User Author { get; set; }
|
||||
|
||||
public bool Published { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public DateOnly Created { get; set; }
|
||||
|
||||
public TimeOnly At { get; set; }
|
||||
|
||||
public ListSortDirection Type { get; set; }
|
||||
|
||||
public TypeCode? TypeCode { get; set; }
|
||||
}
|
||||
19
testing/HopFrame.Testing/Models/User.cs
Normal file
19
testing/HopFrame.Testing/Models/User.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HopFrame.Testing.Models;
|
||||
|
||||
public class User {
|
||||
[Key]
|
||||
public required Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required string Email { get; init; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
|
||||
public virtual List<Post> Posts { get; set; } = new();
|
||||
|
||||
public override string ToString() {
|
||||
return Username;
|
||||
}
|
||||
}
|
||||
99
testing/HopFrame.Testing/Program.cs
Normal file
99
testing/HopFrame.Testing/Program.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Collections;
|
||||
using HopFrame.Testing;
|
||||
using Microsoft.FluentUI.AspNetCore.Components;
|
||||
using HopFrame.Testing.Components;
|
||||
using HopFrame.Testing.Models;
|
||||
using HopFrame.Web;
|
||||
using HopFrame.Web.Components.Pages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddFluentUIComponents();
|
||||
|
||||
builder.Services.AddDbContext<DatabaseContext>(options => {
|
||||
options.UseInMemoryDatabase("testing");
|
||||
});
|
||||
|
||||
builder.Services.AddHopFrame(options => {
|
||||
options.DisplayUserInfo(false);
|
||||
options.AddDbContext<DatabaseContext>(context => {
|
||||
context.Table<User>(table => {
|
||||
table.Property(u => u.Password)
|
||||
.SetParser((pwd, _) => pwd + "-edited");
|
||||
|
||||
table.Property(u => u.FirstName)
|
||||
.List(false);
|
||||
|
||||
table.Property(u => u.LastName)
|
||||
.List(false);
|
||||
|
||||
table.Property(u => u.Id)
|
||||
.IsSortable(false)
|
||||
.SetOrderIndex(3);
|
||||
|
||||
table.AddListingProperty("Name", (user, _) => $"{user.FirstName} {user.LastName}")
|
||||
.SetOrderIndex(2);
|
||||
|
||||
table.SetDisplayName("Benutzer");
|
||||
table.SetDescription("This table is used for user data store and user authentication");
|
||||
|
||||
table.SetViewPolicy("policy");
|
||||
|
||||
table.Property(u => u.Posts)
|
||||
.FormatEach<Post>((post, _) => post.Caption);
|
||||
});
|
||||
|
||||
context.Table<Post>()
|
||||
.Property(p => p.Author)
|
||||
.Format((user, _) => $"{user.FirstName} {user.LastName}");
|
||||
|
||||
context.Table<Post>()
|
||||
.Property(p => p.Id)
|
||||
.SetDisplayName("ID");
|
||||
|
||||
context.Table<Post>()
|
||||
.Property(p => p.CreatedAt);
|
||||
|
||||
context.Table<Post>()
|
||||
.Property(p => p.Content)
|
||||
.IsTextArea(true)
|
||||
/*.Validator(input => {
|
||||
var errors = new List<string>();
|
||||
|
||||
if (input is null)
|
||||
errors.Add("Value cannot be null");
|
||||
|
||||
if (input?.Length > 10)
|
||||
errors.Add("Value can only be 10 characters long");
|
||||
|
||||
return errors;
|
||||
})*/;
|
||||
|
||||
context.Table<Post>()
|
||||
.SetOrderIndex(-1);
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment()) {
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(HopFrameHome).Assembly);
|
||||
|
||||
app.Run();
|
||||
23
testing/HopFrame.Testing/Properties/launchSettings.json
Normal file
23
testing/HopFrame.Testing/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7180;http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
testing/HopFrame.Testing/appsettings.Development.json
Normal file
8
testing/HopFrame.Testing/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
testing/HopFrame.Testing/appsettings.json
Normal file
9
testing/HopFrame.Testing/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
191
testing/HopFrame.Testing/wwwroot/app.css
Normal file
191
testing/HopFrame.Testing/wwwroot/app.css
Normal file
@@ -0,0 +1,191 @@
|
||||
@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css';
|
||||
|
||||
body {
|
||||
--body-font: "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||
font-family: var(--body-font);
|
||||
font-size: var(--type-ramp-base-font-size);
|
||||
line-height: var(--type-ramp-base-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navmenu-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: calc(100dvh - 86px);
|
||||
color: var(--neutral-foreground-rest);
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.body-content {
|
||||
align-self: stretch;
|
||||
height: calc(100dvh - 86px) !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.5rem 1.5rem;
|
||||
align-self: stretch !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manage {
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border: 1px dashed var(--accent-fill-rest);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::before {
|
||||
content: "An error has occurred. "
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
margin: 20vh auto 1rem auto;
|
||||
}
|
||||
|
||||
.loading-progress circle {
|
||||
fill: none;
|
||||
stroke: #e0e0e0;
|
||||
stroke-width: 0.6rem;
|
||||
transform-origin: 50% 50%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.loading-progress circle:last-child {
|
||||
stroke: #1b6ec2;
|
||||
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
|
||||
transition: stroke-dasharray 0.05s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-progress-text {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
|
||||
}
|
||||
|
||||
.loading-progress-text:after {
|
||||
content: var(--blazor-load-percentage-text, "Loading");
|
||||
}
|
||||
|
||||
code {
|
||||
color: #c02d76;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-gutters {
|
||||
margin: 0.5rem 3rem 0.5rem 1.5rem !important;
|
||||
}
|
||||
|
||||
[dir="rtl"] .header-gutters {
|
||||
margin: 0.5rem 1.5rem 0.5rem 3rem !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-direction: column !important;
|
||||
row-gap: 0 !important;
|
||||
}
|
||||
|
||||
nav.sitenav {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#main-menu {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
#main-menu > div:first-child:is(.expander) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navmenu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#navmenu-toggle {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
#navmenu-toggle ~ nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#navmenu-toggle:checked ~ nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navmenu-icon {
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: unset;
|
||||
right: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
[dir="rtl"] .navmenu-icon {
|
||||
left: 20px;
|
||||
right: unset;
|
||||
}
|
||||
}
|
||||
BIN
testing/HopFrame.Testing/wwwroot/favicon.ico
Normal file
BIN
testing/HopFrame.Testing/wwwroot/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user