From 94148a8e2132d6e34d787c2c68abb0bd86e7bd66 Mon Sep 17 00:00:00 2001 From: Manpreet Singh <166604315+Code-311@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:03:37 +0530 Subject: [PATCH] Fix remaining CI build/test issues in MVC, tests, and internals visibility --- .github/workflows/ci.yml | 28 +++ Code311.sln | 171 +++++++++++++++ Directory.Build.props | 25 +++ Directory.Build.targets | 9 + Directory.Packages.props | 19 ++ README.md | 91 ++++++++ docs/adr/ADR-0001-phase0-baseline.md | 13 ++ docs/adr/ADR-TEMPLATE.md | 13 ++ docs/adr/README.md | 3 + docs/architecture-proposal.md | 179 ++++++++++++++++ global.json | 7 + src/Code311.Host/.gitkeep | 1 + src/Code311.Host/Code311.Host.csproj | 28 +++ .../Controllers/ArchitectureController.cs | 8 + .../Controllers/ComponentsDemoController.cs | 13 ++ .../Controllers/DashboardController.cs | 15 ++ .../Controllers/HomeController.cs | 8 + .../Controllers/PreferencesController.cs | 25 +++ .../Controllers/WidgetsController.cs | 50 +++++ src/Code311.Host/Models/HostViewModels.cs | 12 ++ .../Pages/Diagnostics/Licensing.cshtml | 17 ++ .../Pages/Diagnostics/Licensing.cshtml.cs | 19 ++ src/Code311.Host/Pages/_ViewImports.cshtml | 3 + src/Code311.Host/Pages/_ViewStart.cshtml | 3 + src/Code311.Host/Program.cs | 93 ++++++++ src/Code311.Host/Services/DemoUserContext.cs | 13 ++ .../Services/HostStartupLicenseException.cs | 6 + .../Services/PreferenceOrchestrator.cs | 48 +++++ .../Views/Architecture/About.cshtml | 7 + .../Views/ComponentsDemo/Data.cshtml | 2 + .../Views/ComponentsDemo/Feedback.cshtml | 2 + .../Views/ComponentsDemo/Forms.cshtml | 11 + .../Views/ComponentsDemo/Layout.cshtml | 5 + .../Views/ComponentsDemo/Media.cshtml | 2 + .../Views/ComponentsDemo/Navigation.cshtml | 2 + src/Code311.Host/Views/Dashboard/Index.cshtml | 15 ++ src/Code311.Host/Views/Home/Index.cshtml | 12 ++ .../Views/Preferences/Index.cshtml | 24 +++ src/Code311.Host/Views/Shared/_Layout.cshtml | 25 +++ .../Views/Widgets/DataTables.cshtml | 8 + .../Views/Widgets/WidgetPage.cshtml | 9 + src/Code311.Host/Views/_ViewImports.cshtml | 5 + src/Code311.Host/Views/_ViewStart.cshtml | 3 + src/Code311.Host/appsettings.json | 5 + src/Code311.Licensing/.gitkeep | 1 + .../Code311.Licensing.csproj | 13 ++ .../ServiceCollectionExtensions.cs | 48 +++++ .../Diagnostics/LicensingDiagnostics.cs | 29 +++ src/Code311.Licensing/Models/LicenseModels.cs | 49 +++++ .../Models/LicenseStatusModels.cs | 35 ++++ .../Sources/LicenseSources.cs | 41 ++++ .../Validation/LicenseValidationServices.cs | 129 ++++++++++++ src/Code311.Persistence.EFCore/.gitkeep | 1 + .../Code311.Persistence.EFCore.csproj | 19 ++ .../Code311PreferenceDbContext.cs | 19 ++ .../ServiceCollectionExtensions.cs | 31 +++ .../Entities/UserUiPreferenceEntity.cs | 19 ++ .../Extensions/ModelBuilderExtensions.cs | 20 ++ .../UserUiPreferenceEntityConfiguration.cs | 46 ++++ .../Stores/EfCoreUserUiPreferenceStore.cs | 83 ++++++++ src/Code311.Tabler.Components/.gitkeep | 1 + .../Code311.Tabler.Components.csproj | 17 ++ .../Common/ComponentModels.cs | 33 +++ .../Common/TagHelperUtility.cs | 38 ++++ .../Data/DataComponents.cs | 98 +++++++++ .../ServiceCollectionExtensions.cs | 26 +++ .../Feedback/FeedbackComponents.cs | 103 +++++++++ .../Forms/FormTagHelpers.cs | 154 ++++++++++++++ .../Forms/FormViewComponents.cs | 39 ++++ .../Layout/LayoutComponents.cs | 94 +++++++++ .../Media/MediaComponents.cs | 70 +++++++ .../Navigation/NavigationComponents.cs | 84 ++++++++ src/Code311.Tabler.Core/.gitkeep | 1 + .../Assets/TablerAssets.cs | 60 ++++++ .../Code311.Tabler.Core.csproj | 16 ++ .../ServiceCollectionExtensions.cs | 43 ++++ .../Mapping/TablerSemanticClassMapper.cs | 198 ++++++++++++++++++ .../Theming/TablerThemeMapper.cs | 111 ++++++++++ .../Widgets/WidgetSlotContracts.cs | 26 +++ src/Code311.Tabler.Dashboard/.gitkeep | 1 + .../Code311.Tabler.Dashboard.csproj | 18 ++ .../DashboardCompositionContracts.cs | 47 +++++ .../ServiceCollectionExtensions.cs | 35 ++++ .../Kpi/KpiViewComponents.cs | 21 ++ .../Layout/DashboardShellViewComponent.cs | 41 ++++ .../Models/DashboardModels.cs | 60 ++++++ .../Panels/DashboardPanelViewComponents.cs | 44 ++++ .../Properties/AssemblyInfo.cs | 3 + src/Code311.Tabler.Mvc/.gitkeep | 1 + src/Code311.Tabler.Mvc/Assets/AssetModels.cs | 57 +++++ .../WidgetAssetRequestStoreExtensions.cs | 25 +++ .../Code311.Tabler.Mvc.csproj | 17 ++ .../ServiceCollectionExtensions.cs | 69 ++++++ .../Feedback/RequestFeedback.cs | 37 ++++ .../Filters/Code311MvcFilters.cs | 88 ++++++++ .../Helpers/Code311ControllerBase.cs | 27 +++ .../Helpers/HtmlHelperExtensions.cs | 67 ++++++ .../Properties/AssemblyInfo.cs | 3 + .../Theming/ThemeRequestContext.cs | 22 ++ src/Code311.Tabler.Razor/.gitkeep | 1 + .../Assets/AssetModels.cs | 37 ++++ .../WidgetAssetRequestStoreExtensions.cs | 25 +++ .../Code311.Tabler.Razor.csproj | 17 ++ .../ServiceCollectionExtensions.cs | 60 ++++++ .../Feedback/RequestFeedback.cs | 25 +++ .../Filters/Code311RazorFilters.cs | 76 +++++++ .../Helpers/Code311PageModelBase.cs | 22 ++ .../Helpers/PageModelExtensions.cs | 46 ++++ .../Properties/AssemblyInfo.cs | 3 + .../Theming/ThemeRequestContext.cs | 16 ++ src/Code311.Tabler.Widgets.Calendar/.gitkeep | 1 + .../Assets/CalendarWidgetAssets.cs | 17 ++ .../Code311.Tabler.Widgets.Calendar.csproj | 13 ++ .../ServiceCollectionExtensions.cs | 15 ++ .../Options/CalendarWidgetOptions.cs | 31 +++ .../Widgets/CalendarWidgetSlot.cs | 26 +++ src/Code311.Tabler.Widgets.Charts/.gitkeep | 1 + .../Assets/ChartWidgetAssets.cs | 17 ++ .../Code311.Tabler.Widgets.Charts.csproj | 13 ++ .../ServiceCollectionExtensions.cs | 15 ++ .../Options/ChartWidgetOptions.cs | 31 +++ .../Widgets/ChartWidgetSlot.cs | 26 +++ .../.gitkeep | 1 + .../Assets/DataTableWidgetAssets.cs | 17 ++ .../Code311.Tabler.Widgets.DataTables.csproj | 13 ++ .../ServiceCollectionExtensions.cs | 15 ++ .../Options/DataTableWidgetOptions.cs | 51 +++++ .../Widgets/DataTableWidgetSlot.cs | 28 +++ .../Code311.Ui.Abstractions.csproj | 9 + .../Contracts/InternalContractCatalog.cs | 50 +++++ .../Options/UiFrameworkOptions.cs | 36 ++++ .../Preferences/PreferenceContracts.cs | 33 +++ .../Preferences/UserUiPreference.cs | 84 ++++++++ src/Code311.Ui.Abstractions/README.md | 3 + .../Semantics/UiSemantics.cs | 122 +++++++++++ .../Theming/ThemeContracts.cs | 21 ++ .../Theming/ThemeModels.cs | 87 ++++++++ .../Widgets/WidgetContracts.cs | 136 ++++++++++++ src/Code311.Ui.Core/.gitkeep | 1 + src/Code311.Ui.Core/Code311.Ui.Core.csproj | 15 ++ .../ServiceCollectionExtensions.cs | 47 +++++ .../Feedback/FeedbackChannel.cs | 73 +++++++ .../Loading/LoadingOrchestration.cs | 161 ++++++++++++++ src/Code311.Ui.Core/Theming/ThemeRegistry.cs | 134 ++++++++++++ .../Code311.Tests.Host.csproj | 18 ++ .../PreferenceOrchestratorTests.cs | 50 +++++ tests/Code311.Tests.Integration.Mvc/.gitkeep | 1 + .../Code311.Tests.Integration.Mvc.csproj | 16 ++ .../MvcIntegrationTests.cs | 112 ++++++++++ .../Code311.Tests.Integration.Razor/.gitkeep | 1 + .../Code311.Tests.Integration.Razor.csproj | 16 ++ .../RazorIntegrationTests.cs | 118 +++++++++++ tests/Code311.Tests.Licensing/.gitkeep | 1 + .../Code311.Tests.Licensing.csproj | 18 ++ .../LicensingServicesTests.cs | 146 +++++++++++++ .../Code311.Tests.Persistence.EFCore/.gitkeep | 1 + .../Code311.Tests.Persistence.EFCore.csproj | 20 ++ .../EfCoreUserUiPreferenceStoreTests.cs | 98 +++++++++ .../ModelBuilderExtensionsTests.cs | 32 +++ .../Code311.Tests.Tabler.Components/.gitkeep | 1 + .../Code311.Tests.Tabler.Components.csproj | 19 ++ .../ComponentRenderingTests.cs | 140 +++++++++++++ tests/Code311.Tests.Tabler.Core/.gitkeep | 1 + .../Code311.Tests.Tabler.Core.csproj | 17 ++ .../TablerCoreTests.cs | 78 +++++++ tests/Code311.Tests.Tabler.Dashboard/.gitkeep | 1 + .../Code311.Tests.Tabler.Dashboard.csproj | 18 ++ .../DashboardCompositionTests.cs | 95 +++++++++ .../CalendarWidgetTests.cs | 36 ++++ ...de311.Tests.Tabler.Widgets.Calendar.csproj | 17 ++ .../ChartWidgetTests.cs | 36 ++++ ...Code311.Tests.Tabler.Widgets.Charts.csproj | 17 ++ ...311.Tests.Tabler.Widgets.DataTables.csproj | 17 ++ .../DataTableWidgetTests.cs | 37 ++++ .../Code311.Tests.Ui.Abstractions.csproj | 17 ++ .../ThemeAndPreferenceTests.cs | 59 ++++++ .../WidgetContractTests.cs | 45 ++++ tests/Code311.Tests.Ui.Core/.gitkeep | 1 + .../Code311.Tests.Ui.Core.csproj | 17 ++ .../ThemeAndOrchestrationTests.cs | 121 +++++++++++ tests/Code311.Tests.Widgets/.gitkeep | 1 + 181 files changed, 6645 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Code311.sln create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 README.md create mode 100644 docs/adr/ADR-0001-phase0-baseline.md create mode 100644 docs/adr/ADR-TEMPLATE.md create mode 100644 docs/adr/README.md create mode 100644 docs/architecture-proposal.md create mode 100644 global.json create mode 100644 src/Code311.Host/.gitkeep create mode 100644 src/Code311.Host/Code311.Host.csproj create mode 100644 src/Code311.Host/Controllers/ArchitectureController.cs create mode 100644 src/Code311.Host/Controllers/ComponentsDemoController.cs create mode 100644 src/Code311.Host/Controllers/DashboardController.cs create mode 100644 src/Code311.Host/Controllers/HomeController.cs create mode 100644 src/Code311.Host/Controllers/PreferencesController.cs create mode 100644 src/Code311.Host/Controllers/WidgetsController.cs create mode 100644 src/Code311.Host/Models/HostViewModels.cs create mode 100644 src/Code311.Host/Pages/Diagnostics/Licensing.cshtml create mode 100644 src/Code311.Host/Pages/Diagnostics/Licensing.cshtml.cs create mode 100644 src/Code311.Host/Pages/_ViewImports.cshtml create mode 100644 src/Code311.Host/Pages/_ViewStart.cshtml create mode 100644 src/Code311.Host/Program.cs create mode 100644 src/Code311.Host/Services/DemoUserContext.cs create mode 100644 src/Code311.Host/Services/HostStartupLicenseException.cs create mode 100644 src/Code311.Host/Services/PreferenceOrchestrator.cs create mode 100644 src/Code311.Host/Views/Architecture/About.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Data.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Feedback.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Forms.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Layout.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Media.cshtml create mode 100644 src/Code311.Host/Views/ComponentsDemo/Navigation.cshtml create mode 100644 src/Code311.Host/Views/Dashboard/Index.cshtml create mode 100644 src/Code311.Host/Views/Home/Index.cshtml create mode 100644 src/Code311.Host/Views/Preferences/Index.cshtml create mode 100644 src/Code311.Host/Views/Shared/_Layout.cshtml create mode 100644 src/Code311.Host/Views/Widgets/DataTables.cshtml create mode 100644 src/Code311.Host/Views/Widgets/WidgetPage.cshtml create mode 100644 src/Code311.Host/Views/_ViewImports.cshtml create mode 100644 src/Code311.Host/Views/_ViewStart.cshtml create mode 100644 src/Code311.Host/appsettings.json create mode 100644 src/Code311.Licensing/.gitkeep create mode 100644 src/Code311.Licensing/Code311.Licensing.csproj create mode 100644 src/Code311.Licensing/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Licensing/Diagnostics/LicensingDiagnostics.cs create mode 100644 src/Code311.Licensing/Models/LicenseModels.cs create mode 100644 src/Code311.Licensing/Models/LicenseStatusModels.cs create mode 100644 src/Code311.Licensing/Sources/LicenseSources.cs create mode 100644 src/Code311.Licensing/Validation/LicenseValidationServices.cs create mode 100644 src/Code311.Persistence.EFCore/.gitkeep create mode 100644 src/Code311.Persistence.EFCore/Code311.Persistence.EFCore.csproj create mode 100644 src/Code311.Persistence.EFCore/Code311PreferenceDbContext.cs create mode 100644 src/Code311.Persistence.EFCore/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Persistence.EFCore/Entities/UserUiPreferenceEntity.cs create mode 100644 src/Code311.Persistence.EFCore/Extensions/ModelBuilderExtensions.cs create mode 100644 src/Code311.Persistence.EFCore/Mapping/UserUiPreferenceEntityConfiguration.cs create mode 100644 src/Code311.Persistence.EFCore/Stores/EfCoreUserUiPreferenceStore.cs create mode 100644 src/Code311.Tabler.Components/.gitkeep create mode 100644 src/Code311.Tabler.Components/Code311.Tabler.Components.csproj create mode 100644 src/Code311.Tabler.Components/Common/ComponentModels.cs create mode 100644 src/Code311.Tabler.Components/Common/TagHelperUtility.cs create mode 100644 src/Code311.Tabler.Components/Data/DataComponents.cs create mode 100644 src/Code311.Tabler.Components/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Components/Feedback/FeedbackComponents.cs create mode 100644 src/Code311.Tabler.Components/Forms/FormTagHelpers.cs create mode 100644 src/Code311.Tabler.Components/Forms/FormViewComponents.cs create mode 100644 src/Code311.Tabler.Components/Layout/LayoutComponents.cs create mode 100644 src/Code311.Tabler.Components/Media/MediaComponents.cs create mode 100644 src/Code311.Tabler.Components/Navigation/NavigationComponents.cs create mode 100644 src/Code311.Tabler.Core/.gitkeep create mode 100644 src/Code311.Tabler.Core/Assets/TablerAssets.cs create mode 100644 src/Code311.Tabler.Core/Code311.Tabler.Core.csproj create mode 100644 src/Code311.Tabler.Core/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Core/Mapping/TablerSemanticClassMapper.cs create mode 100644 src/Code311.Tabler.Core/Theming/TablerThemeMapper.cs create mode 100644 src/Code311.Tabler.Core/Widgets/WidgetSlotContracts.cs create mode 100644 src/Code311.Tabler.Dashboard/.gitkeep create mode 100644 src/Code311.Tabler.Dashboard/Code311.Tabler.Dashboard.csproj create mode 100644 src/Code311.Tabler.Dashboard/Composition/DashboardCompositionContracts.cs create mode 100644 src/Code311.Tabler.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Dashboard/Kpi/KpiViewComponents.cs create mode 100644 src/Code311.Tabler.Dashboard/Layout/DashboardShellViewComponent.cs create mode 100644 src/Code311.Tabler.Dashboard/Models/DashboardModels.cs create mode 100644 src/Code311.Tabler.Dashboard/Panels/DashboardPanelViewComponents.cs create mode 100644 src/Code311.Tabler.Dashboard/Properties/AssemblyInfo.cs create mode 100644 src/Code311.Tabler.Mvc/.gitkeep create mode 100644 src/Code311.Tabler.Mvc/Assets/AssetModels.cs create mode 100644 src/Code311.Tabler.Mvc/Assets/WidgetAssetRequestStoreExtensions.cs create mode 100644 src/Code311.Tabler.Mvc/Code311.Tabler.Mvc.csproj create mode 100644 src/Code311.Tabler.Mvc/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Mvc/Feedback/RequestFeedback.cs create mode 100644 src/Code311.Tabler.Mvc/Filters/Code311MvcFilters.cs create mode 100644 src/Code311.Tabler.Mvc/Helpers/Code311ControllerBase.cs create mode 100644 src/Code311.Tabler.Mvc/Helpers/HtmlHelperExtensions.cs create mode 100644 src/Code311.Tabler.Mvc/Properties/AssemblyInfo.cs create mode 100644 src/Code311.Tabler.Mvc/Theming/ThemeRequestContext.cs create mode 100644 src/Code311.Tabler.Razor/.gitkeep create mode 100644 src/Code311.Tabler.Razor/Assets/AssetModels.cs create mode 100644 src/Code311.Tabler.Razor/Assets/WidgetAssetRequestStoreExtensions.cs create mode 100644 src/Code311.Tabler.Razor/Code311.Tabler.Razor.csproj create mode 100644 src/Code311.Tabler.Razor/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Razor/Feedback/RequestFeedback.cs create mode 100644 src/Code311.Tabler.Razor/Filters/Code311RazorFilters.cs create mode 100644 src/Code311.Tabler.Razor/Helpers/Code311PageModelBase.cs create mode 100644 src/Code311.Tabler.Razor/Helpers/PageModelExtensions.cs create mode 100644 src/Code311.Tabler.Razor/Properties/AssemblyInfo.cs create mode 100644 src/Code311.Tabler.Razor/Theming/ThemeRequestContext.cs create mode 100644 src/Code311.Tabler.Widgets.Calendar/.gitkeep create mode 100644 src/Code311.Tabler.Widgets.Calendar/Assets/CalendarWidgetAssets.cs create mode 100644 src/Code311.Tabler.Widgets.Calendar/Code311.Tabler.Widgets.Calendar.csproj create mode 100644 src/Code311.Tabler.Widgets.Calendar/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Widgets.Calendar/Options/CalendarWidgetOptions.cs create mode 100644 src/Code311.Tabler.Widgets.Calendar/Widgets/CalendarWidgetSlot.cs create mode 100644 src/Code311.Tabler.Widgets.Charts/.gitkeep create mode 100644 src/Code311.Tabler.Widgets.Charts/Assets/ChartWidgetAssets.cs create mode 100644 src/Code311.Tabler.Widgets.Charts/Code311.Tabler.Widgets.Charts.csproj create mode 100644 src/Code311.Tabler.Widgets.Charts/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Widgets.Charts/Options/ChartWidgetOptions.cs create mode 100644 src/Code311.Tabler.Widgets.Charts/Widgets/ChartWidgetSlot.cs create mode 100644 src/Code311.Tabler.Widgets.DataTables/.gitkeep create mode 100644 src/Code311.Tabler.Widgets.DataTables/Assets/DataTableWidgetAssets.cs create mode 100644 src/Code311.Tabler.Widgets.DataTables/Code311.Tabler.Widgets.DataTables.csproj create mode 100644 src/Code311.Tabler.Widgets.DataTables/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Tabler.Widgets.DataTables/Options/DataTableWidgetOptions.cs create mode 100644 src/Code311.Tabler.Widgets.DataTables/Widgets/DataTableWidgetSlot.cs create mode 100644 src/Code311.Ui.Abstractions/Code311.Ui.Abstractions.csproj create mode 100644 src/Code311.Ui.Abstractions/Internal/Contracts/InternalContractCatalog.cs create mode 100644 src/Code311.Ui.Abstractions/Options/UiFrameworkOptions.cs create mode 100644 src/Code311.Ui.Abstractions/Preferences/PreferenceContracts.cs create mode 100644 src/Code311.Ui.Abstractions/Preferences/UserUiPreference.cs create mode 100644 src/Code311.Ui.Abstractions/README.md create mode 100644 src/Code311.Ui.Abstractions/Semantics/UiSemantics.cs create mode 100644 src/Code311.Ui.Abstractions/Theming/ThemeContracts.cs create mode 100644 src/Code311.Ui.Abstractions/Theming/ThemeModels.cs create mode 100644 src/Code311.Ui.Abstractions/Widgets/WidgetContracts.cs create mode 100644 src/Code311.Ui.Core/.gitkeep create mode 100644 src/Code311.Ui.Core/Code311.Ui.Core.csproj create mode 100644 src/Code311.Ui.Core/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Code311.Ui.Core/Feedback/FeedbackChannel.cs create mode 100644 src/Code311.Ui.Core/Loading/LoadingOrchestration.cs create mode 100644 src/Code311.Ui.Core/Theming/ThemeRegistry.cs create mode 100644 tests/Code311.Tests.Host/Code311.Tests.Host.csproj create mode 100644 tests/Code311.Tests.Host/PreferenceOrchestratorTests.cs create mode 100644 tests/Code311.Tests.Integration.Mvc/.gitkeep create mode 100644 tests/Code311.Tests.Integration.Mvc/Code311.Tests.Integration.Mvc.csproj create mode 100644 tests/Code311.Tests.Integration.Mvc/MvcIntegrationTests.cs create mode 100644 tests/Code311.Tests.Integration.Razor/.gitkeep create mode 100644 tests/Code311.Tests.Integration.Razor/Code311.Tests.Integration.Razor.csproj create mode 100644 tests/Code311.Tests.Integration.Razor/RazorIntegrationTests.cs create mode 100644 tests/Code311.Tests.Licensing/.gitkeep create mode 100644 tests/Code311.Tests.Licensing/Code311.Tests.Licensing.csproj create mode 100644 tests/Code311.Tests.Licensing/LicensingServicesTests.cs create mode 100644 tests/Code311.Tests.Persistence.EFCore/.gitkeep create mode 100644 tests/Code311.Tests.Persistence.EFCore/Code311.Tests.Persistence.EFCore.csproj create mode 100644 tests/Code311.Tests.Persistence.EFCore/EfCoreUserUiPreferenceStoreTests.cs create mode 100644 tests/Code311.Tests.Persistence.EFCore/ModelBuilderExtensionsTests.cs create mode 100644 tests/Code311.Tests.Tabler.Components/.gitkeep create mode 100644 tests/Code311.Tests.Tabler.Components/Code311.Tests.Tabler.Components.csproj create mode 100644 tests/Code311.Tests.Tabler.Components/ComponentRenderingTests.cs create mode 100644 tests/Code311.Tests.Tabler.Core/.gitkeep create mode 100644 tests/Code311.Tests.Tabler.Core/Code311.Tests.Tabler.Core.csproj create mode 100644 tests/Code311.Tests.Tabler.Core/TablerCoreTests.cs create mode 100644 tests/Code311.Tests.Tabler.Dashboard/.gitkeep create mode 100644 tests/Code311.Tests.Tabler.Dashboard/Code311.Tests.Tabler.Dashboard.csproj create mode 100644 tests/Code311.Tests.Tabler.Dashboard/DashboardCompositionTests.cs create mode 100644 tests/Code311.Tests.Tabler.Widgets.Calendar/CalendarWidgetTests.cs create mode 100644 tests/Code311.Tests.Tabler.Widgets.Calendar/Code311.Tests.Tabler.Widgets.Calendar.csproj create mode 100644 tests/Code311.Tests.Tabler.Widgets.Charts/ChartWidgetTests.cs create mode 100644 tests/Code311.Tests.Tabler.Widgets.Charts/Code311.Tests.Tabler.Widgets.Charts.csproj create mode 100644 tests/Code311.Tests.Tabler.Widgets.DataTables/Code311.Tests.Tabler.Widgets.DataTables.csproj create mode 100644 tests/Code311.Tests.Tabler.Widgets.DataTables/DataTableWidgetTests.cs create mode 100644 tests/Code311.Tests.Ui.Abstractions/Code311.Tests.Ui.Abstractions.csproj create mode 100644 tests/Code311.Tests.Ui.Abstractions/ThemeAndPreferenceTests.cs create mode 100644 tests/Code311.Tests.Ui.Abstractions/WidgetContractTests.cs create mode 100644 tests/Code311.Tests.Ui.Core/.gitkeep create mode 100644 tests/Code311.Tests.Ui.Core/Code311.Tests.Ui.Core.csproj create mode 100644 tests/Code311.Tests.Ui.Core/ThemeAndOrchestrationTests.cs create mode 100644 tests/Code311.Tests.Widgets/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea91da6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore Code311.sln + + - name: Build + run: dotnet build Code311.sln --configuration Release --no-restore + + - name: Test + run: dotnet test Code311.sln --configuration Release --no-build --verbosity normal diff --git a/Code311.sln b/Code311.sln new file mode 100644 index 0000000..ee253ba --- /dev/null +++ b/Code311.sln @@ -0,0 +1,171 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Ui.Abstractions", "src\Code311.Ui.Abstractions\Code311.Ui.Abstractions.csproj", "{A1111111-1111-1111-1111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Ui.Core", "src\Code311.Ui.Core\Code311.Ui.Core.csproj", "{A1111111-1111-1111-1111-111111111112}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Core", "src\Code311.Tabler.Core\Code311.Tabler.Core.csproj", "{A1111111-1111-1111-1111-111111111113}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Components", "src\Code311.Tabler.Components\Code311.Tabler.Components.csproj", "{A1111111-1111-1111-1111-111111111114}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Mvc", "src\Code311.Tabler.Mvc\Code311.Tabler.Mvc.csproj", "{A1111111-1111-1111-1111-111111111115}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Razor", "src\Code311.Tabler.Razor\Code311.Tabler.Razor.csproj", "{A1111111-1111-1111-1111-111111111116}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Dashboard", "src\Code311.Tabler.Dashboard\Code311.Tabler.Dashboard.csproj", "{A1111111-1111-1111-1111-111111111117}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Ui.Abstractions", "tests\Code311.Tests.Ui.Abstractions\Code311.Tests.Ui.Abstractions.csproj", "{B2222222-2222-2222-2222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Ui.Core", "tests\Code311.Tests.Ui.Core\Code311.Tests.Ui.Core.csproj", "{B2222222-2222-2222-2222-222222222223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Core", "tests\Code311.Tests.Tabler.Core\Code311.Tests.Tabler.Core.csproj", "{B2222222-2222-2222-2222-222222222224}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Components", "tests\Code311.Tests.Tabler.Components\Code311.Tests.Tabler.Components.csproj", "{B2222222-2222-2222-2222-222222222225}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Integration.Mvc", "tests\Code311.Tests.Integration.Mvc\Code311.Tests.Integration.Mvc.csproj", "{B2222222-2222-2222-2222-222222222226}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Integration.Razor", "tests\Code311.Tests.Integration.Razor\Code311.Tests.Integration.Razor.csproj", "{B2222222-2222-2222-2222-222222222227}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Dashboard", "tests\Code311.Tests.Tabler.Dashboard\Code311.Tests.Tabler.Dashboard.csproj", "{B2222222-2222-2222-2222-222222222228}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Widgets.DataTables", "src\Code311.Tabler.Widgets.DataTables\Code311.Tabler.Widgets.DataTables.csproj", "{A1111111-1111-1111-1111-111111111118}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Widgets.Calendar", "src\Code311.Tabler.Widgets.Calendar\Code311.Tabler.Widgets.Calendar.csproj", "{A1111111-1111-1111-1111-111111111119}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tabler.Widgets.Charts", "src\Code311.Tabler.Widgets.Charts\Code311.Tabler.Widgets.Charts.csproj", "{A1111111-1111-1111-1111-111111111120}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Widgets.DataTables", "tests\Code311.Tests.Tabler.Widgets.DataTables\Code311.Tests.Tabler.Widgets.DataTables.csproj", "{B2222222-2222-2222-2222-222222222229}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Widgets.Calendar", "tests\Code311.Tests.Tabler.Widgets.Calendar\Code311.Tests.Tabler.Widgets.Calendar.csproj", "{B2222222-2222-2222-2222-222222222230}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Tabler.Widgets.Charts", "tests\Code311.Tests.Tabler.Widgets.Charts\Code311.Tests.Tabler.Widgets.Charts.csproj", "{B2222222-2222-2222-2222-222222222231}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Persistence.EFCore", "src\Code311.Persistence.EFCore\Code311.Persistence.EFCore.csproj", "{A1111111-1111-1111-1111-111111111121}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Persistence.EFCore", "tests\Code311.Tests.Persistence.EFCore\Code311.Tests.Persistence.EFCore.csproj", "{B2222222-2222-2222-2222-222222222232}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Licensing", "src\Code311.Licensing\Code311.Licensing.csproj", "{A1111111-1111-1111-1111-111111111122}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Licensing", "tests\Code311.Tests.Licensing\Code311.Tests.Licensing.csproj", "{B2222222-2222-2222-2222-222222222233}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Host", "src\Code311.Host\Code311.Host.csproj", "{A1111111-1111-1111-1111-111111111123}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Host", "tests\Code311.Tests.Host\Code311.Tests.Host.csproj", "{B2222222-2222-2222-2222-222222222234}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111112}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111112}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111112}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111112}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111113}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111113}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111113}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111113}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111114}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111114}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111114}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111114}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111115}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111115}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111115}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111115}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111116}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111116}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111116}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111116}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111117}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111117}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111117}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111117}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222223}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222223}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222223}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222224}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222224}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222224}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222224}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222225}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222225}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222225}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222226}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222226}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222226}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222226}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222227}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222227}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222227}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222227}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222228}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222228}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222228}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111118}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111119}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111119}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111119}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111120}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111120}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111120}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111120}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222229}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222229}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222229}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222230}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222230}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222230}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222231}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222231}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222231}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111121}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111121}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111121}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111121}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222232}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222232}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222232}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111122}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111122}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111122}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111122}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222233}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222233}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222233}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222233}.Release|Any CPU.Build.0 = Release|Any CPU + {A1111111-1111-1111-1111-111111111123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111123}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222234}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6246ab5 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,25 @@ + + + enable + enable + preview + latest + true + true + true + $(NoWarn);1591 + true + + + + false + + + + Code311 + Code311 + true + https://example.com/Code311 + README.md + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d84f792 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..4c5b154 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,19 @@ + + + true + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab49142 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Code311 + +Code311 is a modular, design-system-neutral UI framework ecosystem for ASP.NET Core (.NET 10), with a Tabler-backed implementation and an official hybrid MVC + Razor demo host. + +## Repository status + +This repository now includes: + +- **Foundation and governance**: solution layout, SDK pinning, centralized package versions, analyzer/nullability/doc enforcement. +- **Framework-neutral contracts and core orchestration**: + - `Code311.Ui.Abstractions` + - `Code311.Ui.Core` +- **Tabler implementation packages**: + - `Code311.Tabler.Core` + - `Code311.Tabler.Components` + - `Code311.Tabler.Dashboard` + - `Code311.Tabler.Widgets.DataTables` + - `Code311.Tabler.Widgets.Calendar` + - `Code311.Tabler.Widgets.Charts` + - `Code311.Tabler.Mvc` + - `Code311.Tabler.Razor` +- **Infrastructure packages**: + - `Code311.Persistence.EFCore` + - `Code311.Licensing` +- **Hybrid demo/integration portal**: + - `Code311.Host` + +## High-level architecture + +Code311 is intentionally layered: + +1. **Abstractions layer** (`Ui.Abstractions`) defines semantics/contracts with no design-system coupling. +2. **Core layer** (`Ui.Core`) provides neutral orchestration services (theme/feedback/loading). +3. **Design-system layer** (`Tabler.*`) maps abstractions into concrete UI behavior and rendering for MVC/Razor, dashboard, and widgets. +4. **Cross-cutting infrastructure** (`Persistence.EFCore`, `Licensing`) remains independent from `Ui.Core` and Tabler component primitives. +5. **Host layer** (`Code311.Host`) composes the ecosystem and demonstrates real integration flows without pushing host concerns down into framework packages. + +## Run the host demo (`Code311.Host`) + +> Prerequisites +> +> - .NET SDK 10 (preview) + +From repository root: + +```bash +dotnet restore Code311.sln +dotnet build Code311.sln +dotnet run --project src/Code311.Host/Code311.Host.csproj +``` + +Default host behavior: + +- Uses **SQLite** demo persistence at `src/Code311.Host/App_Data/code311-host-demo.db`. +- Runs explicit startup licensing validation (demo in-memory license configured in host startup). +- Hosts both MVC and Razor Pages routes. + +## Key demo sections/routes + +Base URL assumes local launch profile default (`https://localhost:5001` or similar): + +- Home: `/` +- Components: + - Forms: `/ComponentsDemo/Forms` + - Navigation: `/ComponentsDemo/Navigation` + - Layout: `/ComponentsDemo/Layout` + - Feedback: `/ComponentsDemo/Feedback` + - Data: `/ComponentsDemo/Data` + - Media: `/ComponentsDemo/Media` +- Dashboard: `/Dashboard` +- Widgets: + - DataTables: `/Widgets/DataTables` + - Calendar: `/Widgets/Calendar` + - Charts: `/Widgets/Charts` +- Preferences / Theme: `/Preferences` +- Licensing Diagnostics (Razor Page): `/Diagnostics/Licensing` +- Architecture / About: `/Architecture/About` + +## Test and validation + +Run the full test matrix: + +```bash +dotnet test Code311.sln +``` + +## Repository layout + +- `src/` — product/framework/host packages +- `tests/` — unit and integration test projects +- `docs/` — ADRs and architecture proposal diff --git a/docs/adr/ADR-0001-phase0-baseline.md b/docs/adr/ADR-0001-phase0-baseline.md new file mode 100644 index 0000000..6ad9d10 --- /dev/null +++ b/docs/adr/ADR-0001-phase0-baseline.md @@ -0,0 +1,13 @@ +# ADR-0001: Phase 0 governance baseline + +## Status +Accepted + +## Context +Phase 0 requires repository-level governance and build defaults for a commercial multi-package ecosystem. + +## Decision +Adopt central package management, repository-wide nullable + XML docs + analyzer defaults, SDK pinning via global.json, and deterministic build settings. + +## Consequences +All projects inherit consistent quality gates and package governance from repository root. diff --git a/docs/adr/ADR-TEMPLATE.md b/docs/adr/ADR-TEMPLATE.md new file mode 100644 index 0000000..5fe60b6 --- /dev/null +++ b/docs/adr/ADR-TEMPLATE.md @@ -0,0 +1,13 @@ +# ADR-XXXX: + +## Status +Proposed | Accepted | Superseded + +## Context +Describe the problem and constraints. + +## Decision +Describe the chosen option. + +## Consequences +Describe positive and negative outcomes. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..f497cfd --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,3 @@ +# Architecture Decision Records + +This folder stores ADRs for Code311. diff --git a/docs/architecture-proposal.md b/docs/architecture-proposal.md new file mode 100644 index 0000000..4994d2d --- /dev/null +++ b/docs/architecture-proposal.md @@ -0,0 +1,179 @@ +# Code311 Architecture Proposal (Final Pre-Implementation Refinement) + +## Scope +Architecture-only artifact. No implementation code is included. + +--- + +## Confirmed Foundations + +### Strict dependency graph + +```text +Code311.Ui.Abstractions + ↓ +Code311.Ui.Core + ↓ +Code311.Tabler.Core + ↓ +Code311.Tabler.Components + ↓ +Code311.Tabler.Dashboard + Code311.Tabler.Widgets.* + ↓ +Code311.Tabler.Mvc + Code311.Tabler.Razor + ↓ +Code311.Host +``` + +### Dependency rules +- `Code311.Ui.Abstractions` + - No dependencies. +- `Code311.Ui.Core` + - Depends only on `Code311.Ui.Abstractions`. +- `Code311.Tabler.*` + - May depend on `Code311.Ui.*`; reverse forbidden. +- `Code311.Persistence.EFCore` + - Depends primarily on `Code311.Ui.Abstractions`. + - Provider-agnostic EF Core design; no SQL Server lock-in. +- `Code311.Licensing` + - Independent package. + - Startup validation required; runtime checks only at defined integration points. +- `Code311.Host` + - Terminal application; never referenced by framework packages. + - Hybrid by default (MVC + Razor Pages), demonstrative only. + +### Tooling decision +- Bundler v1: **esbuild** (predictable, low-overhead, deterministic static asset output for RCL/NuGet distribution). + +--- + +## Responsibility Separation (Locked) + +| Area | `Code311.Tabler.Components` | `Code311.Tabler.Dashboard` | `Code311.Tabler.Widgets.*` | +|---|---|---|---| +| Core purpose | Reusable primitives + common composites | Dashboard shell and layout orchestration | Specialized widget integrations | +| Base domain rendering | Yes | No | No | +| Dashboard orchestration | No | Yes | No | +| Widget lifecycle | No | Placement coordination | Yes | +| Third-party JS adapters | Minimal/shared only | None/minimal | Primary ownership | +| Public API style | Semantic neutral parameters | Dashboard composition semantics | Widget-specific semantic options/builders | + +--- + +## 1) Expanded Component Coverage Matrix (`Code311.Tabler.Components`) + +> Parameter vocabulary is normalized to semantic terms (`tone`, `appearance`, `density`, `size`, `state`, `placement`, `layout`, `pinned`) and avoids CSS-framework tokens. + +| Component | Domain | Implementation Type | Primary Semantic Parameters | Internal Dependency Requirements | Phase | Classification | +|---|---|---|---|---|---|---| +| `Cd311Input` | Forms | TagHelper | `field`, `label`, `state`, `size`, `density`, `appearance`, `hint` | `IFieldMetadataProvider`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311TextArea` | Forms | TagHelper | `field`, `state`, `size`, `density`, `appearance`, `hint`, `rows` | `IFieldMetadataProvider`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311Select` | Forms | TagHelper | `field`, `state`, `size`, `density`, `appearance`, `placement`, `options` | `ISelectOptionSource`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311Checkbox` | Forms | TagHelper | `field`, `state`, `size`, `appearance`, `label` | `IFieldMetadataProvider`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311RadioGroup` | Forms | TagHelper | `field`, `state`, `size`, `density`, `layout`, `options` | `IChoiceOptionSource`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311Switch` | Forms | TagHelper | `field`, `state`, `size`, `tone`, `appearance`, `label` | `IFieldMetadataProvider`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311InputGroup` | Forms | TagHelper | `field`, `appearance`, `size`, `density`, `placement`, `state` | `IFieldMetadataProvider`, `IInputAdornmentResolver`, `ITablerFormClassMapper` | Phase 2 | Composite | +| `Cd311DateInput` | Forms | TagHelper | `field`, `state`, `size`, `density`, `appearance`, `placement` | `IDateFormatProvider`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Primitive | +| `Cd311FileUpload` | Forms | ViewComponent | `field`, `state`, `size`, `appearance`, `layout`, `placement` | `IFileUploadPolicy`, `IValidationStateResolver`, `ITablerMediaClassMapper` | Phase 3 | Composite | +| `Cd311SearchBox` | Forms | TagHelper | `field`, `state`, `size`, `appearance`, `placement`, `pinned` | `ISearchQueryBinder`, `IValidationStateResolver`, `ITablerFormClassMapper` | Phase 2 | Pattern helper | +| `Cd311FieldGroup` | Forms | ViewComponent | `layout`, `density`, `appearance`, `state`, `placement`, `legend` | `IFormLayoutPolicy`, `IValidationSummaryComposer`, `ITablerLayoutClassMapper` | Phase 2 | Composite | +| `Cd311ValidationSummary` | Forms | TagHelper | `tone`, `appearance`, `density`, `state`, `placement`, `scope` | `IValidationSummaryComposer`, `IValidationStateResolver`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311FormActionsBar` | Forms | ViewComponent | `layout`, `density`, `appearance`, `placement`, `pinned`, `state` | `IFormActionModelBuilder`, `ICommandPolicyResolver`, `ITablerLayoutClassMapper` | Phase 2 | Pattern helper | +| `Cd311TopNav` | Navigation | ViewComponent | `layout`, `appearance`, `density`, `state`, `pinned`, `placement` | `INavigationModelProvider`, `INavStateResolver`, `ITablerNavigationClassMapper` | Phase 2 | Composite | +| `Cd311SidebarNav` | Navigation | ViewComponent | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `INavigationModelProvider`, `INavStateResolver`, `ITablerNavigationClassMapper` | Phase 2 | Composite | +| `Cd311Breadcrumb` | Navigation | TagHelper | `appearance`, `density`, `size`, `placement`, `state` | `IBreadcrumbProvider`, `IRouteContextAccessor`, `ITablerNavigationClassMapper` | Phase 2 | Primitive | +| `Cd311Pagination` | Navigation | TagHelper | `size`, `appearance`, `density`, `state`, `placement`, `layout` | `IPagingModelFactory`, `IPagingStateResolver`, `ITablerDataClassMapper` | Phase 2 | Primitive | +| `Cd311Tabs` | Navigation | TagHelper | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `ITabSetModelProvider`, `ITabStateResolver`, `ITablerNavigationClassMapper` | Phase 2 | Composite | +| `Cd311DropdownMenu` | Navigation | ViewComponent | `placement`, `appearance`, `size`, `state`, `layout`, `pinned` | `IMenuModelProvider`, `ICommandPolicyResolver`, `ITablerNavigationClassMapper` | Phase 2 | Composite | +| `Cd311CommandBar` | Navigation | ViewComponent | `layout`, `density`, `appearance`, `placement`, `pinned`, `state` | `ICommandModelProvider`, `ICommandPolicyResolver`, `ITablerNavigationClassMapper` | Phase 3 | Pattern helper | +| `Cd311MenuGroup` | Navigation | TagHelper | `layout`, `appearance`, `density`, `state`, `placement` | `IMenuModelProvider`, `INavStateResolver`, `ITablerNavigationClassMapper` | Phase 2 | Composite | +| `Cd311Container` | Layout | TagHelper | `layout`, `density`, `appearance`, `size`, `placement`, `pinned` | `ILayoutProfileResolver`, `IThemeProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Primitive | +| `Cd311Section` | Layout | TagHelper | `layout`, `density`, `appearance`, `state`, `placement`, `pinned` | `ILayoutProfileResolver`, `IThemeProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Primitive | +| `Cd311PageHeader` | Layout | ViewComponent | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `IPageHeaderModelProvider`, `ICommandPolicyResolver`, `ITablerLayoutClassMapper` | Phase 2 | Composite | +| `Cd311Card` | Layout | TagHelper | `tone`, `appearance`, `density`, `size`, `state`, `layout` | `ICardModelPolicy`, `IThemeProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Primitive | +| `Cd311Grid` | Layout | TagHelper | `layout`, `density`, `appearance`, `state`, `placement`, `size` | `IGridLayoutPolicy`, `ILayoutProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Primitive | +| `Cd311Stack` | Layout | TagHelper | `layout`, `density`, `appearance`, `state`, `placement`, `size` | `IStackLayoutPolicy`, `ILayoutProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Primitive | +| `Cd311Accordion` | Layout | ViewComponent | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `IAccordionStateResolver`, `ILayoutProfileResolver`, `ITablerLayoutClassMapper` | Phase 2 | Composite | +| `Cd311Drawer` | Layout | ViewComponent | `placement`, `layout`, `appearance`, `density`, `state`, `pinned` | `IDrawerStateCoordinator`, `ICommandPolicyResolver`, `ITablerLayoutClassMapper` | Phase 3 | Composite | +| `Cd311TabsContainer` | Layout | TagHelper | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `ITabSetModelProvider`, `ILayoutProfileResolver`, `ITablerLayoutClassMapper` | Phase 3 | Pattern helper | +| `Cd311PanelLayout` | Layout | Service + render pattern | `layout`, `density`, `appearance`, `state`, `placement`, `pinned` | `IPanelLayoutComposer`, `ILayoutProfileResolver`, `ITablerLayoutClassMapper` | Phase 3 | Pattern helper | +| `Cd311Alert` | Feedback | TagHelper | `tone`, `appearance`, `density`, `size`, `state`, `placement` | `IAlertQueue`, `IFeedbackSerializer`, `ITablerFeedbackClassMapper` | Phase 2 | Primitive | +| `Cd311ToastHost` | Feedback | ViewComponent | `placement`, `appearance`, `density`, `state`, `size`, `pinned` | `IToastQueue`, `IToastRenderModelFactory`, `ITablerFeedbackClassMapper` | Phase 3 | Composite | +| `Cd311Modal` | Feedback | ViewComponent | `placement`, `appearance`, `size`, `state`, `layout`, `pinned` | `IDialogOrchestrator`, `IDialogStateResolver`, `ITablerFeedbackClassMapper` | Phase 3 | Composite | +| `Cd311ConfirmDialog` | Feedback | ViewComponent | `tone`, `appearance`, `size`, `state`, `placement`, `layout` | `IConfirmInteractionService`, `IDialogOrchestrator`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311PageAlertHost` | Feedback | Service + render pattern | `placement`, `appearance`, `density`, `state`, `layout`, `pinned` | `IPageAlertPipeline`, `IFeedbackSerializer`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311BusyOverlay` | Feedback | TagHelper | `appearance`, `density`, `state`, `placement`, `layout`, `pinned` | `IBusyStateService`, `ILoaderRenderModelFactory`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311BusyButton` | Feedback | TagHelper | `tone`, `appearance`, `size`, `state`, `placement`, `layout` | `IButtonStateCoordinator`, `IBusyStateService`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311LockScreen` | Feedback | ViewComponent | `layout`, `appearance`, `density`, `state`, `placement`, `pinned` | `ILockScreenPolicy`, `IThemeProfileResolver`, `ITablerLayoutClassMapper` | Phase 4 | Composite | +| `Cd311PagePreloader` | Feedback | Service + render pattern | `appearance`, `density`, `state`, `placement`, `layout`, `pinned` | `IPreloadOrchestrator`, `ILoaderRenderModelFactory`, `ITablerFeedbackClassMapper` | Phase 3 | Pattern helper | +| `Cd311Progress` | Feedback | TagHelper | `tone`, `appearance`, `size`, `state`, `placement`, `density` | `IProgressModelFactory`, `IFeedbackSerializer`, `ITablerFeedbackClassMapper` | Phase 2 | Primitive | +| `Cd311Table` | Data | TagHelper | `layout`, `density`, `appearance`, `state`, `size`, `placement` | `ITableModelPolicy`, `ISortStateResolver`, `ITablerDataClassMapper` | Phase 2 | Primitive | +| `Cd311DataGridShell` | Data | ViewComponent | `layout`, `density`, `appearance`, `state`, `placement`, `pinned` | `IDataGridModelBuilder`, `IFilterStateResolver`, `ITablerDataClassMapper` | Phase 3 | Composite | +| `Cd311FilterToolbar` | Data | ViewComponent | `layout`, `density`, `appearance`, `state`, `placement`, `pinned` | `IFilterModelProvider`, `IQueryStateBinder`, `ITablerDataClassMapper` | Phase 3 | Pattern helper | +| `Cd311Badge` | Data | TagHelper | `tone`, `appearance`, `size`, `state`, `placement`, `density` | `IBadgeStylePolicy`, `IThemeProfileResolver`, `ITablerDataClassMapper` | Phase 2 | Primitive | +| `Cd311StatKpi` | Data | ViewComponent | `tone`, `appearance`, `density`, `size`, `state`, `layout` | `IStatModelProvider`, `ITrendIndicatorResolver`, `ITablerDataClassMapper` | Phase 2 | Composite | +| `Cd311ListGroup` | Data | TagHelper | `layout`, `density`, `appearance`, `state`, `placement`, `size` | `IListModelProvider`, `ISelectionStateResolver`, `ITablerDataClassMapper` | Phase 2 | Primitive | +| `Cd311KeyValueList` | Data | TagHelper | `layout`, `density`, `appearance`, `state`, `size`, `placement` | `IMetadataListFormatter`, `ILayoutProfileResolver`, `ITablerDataClassMapper` | Phase 2 | Primitive | +| `Cd311EmptyState` | Data | TagHelper | `tone`, `appearance`, `density`, `state`, `placement`, `layout` | `IEmptyStatePolicy`, `ICommandPolicyResolver`, `ITablerDataClassMapper` | Phase 2 | Pattern helper | +| `Cd311Avatar` | Media | TagHelper | `appearance`, `size`, `state`, `placement`, `density`, `tone` | `IAvatarModelPolicy`, `IMediaUrlResolver`, `ITablerMediaClassMapper` | Phase 2 | Primitive | +| `Cd311Icon` | Media | TagHelper | `appearance`, `size`, `state`, `placement`, `tone`, `density` | `IIconRegistry`, `IIconResolver`, `ITablerMediaClassMapper` | Phase 2 | Primitive | +| `Cd311Image` | Media | TagHelper | `appearance`, `size`, `state`, `placement`, `layout`, `density` | `IMediaUrlResolver`, `IMediaOptimizationPolicy`, `ITablerMediaClassMapper` | Phase 2 | Primitive | +| `Cd311FilePreview` | Media | ViewComponent | `layout`, `appearance`, `size`, `state`, `placement`, `density` | `IFilePreviewModelBuilder`, `IMediaTypeClassifier`, `ITablerMediaClassMapper` | Phase 3 | Composite | +| `Cd311ProfileBlock` | Media | ViewComponent | `layout`, `appearance`, `density`, `state`, `size`, `placement` | `IProfileSummaryProvider`, `IAvatarModelPolicy`, `ITablerMediaClassMapper` | Phase 3 | Composite | +| `Cd311Banner` | Media | ViewComponent | `tone`, `appearance`, `density`, `state`, `placement`, `pinned` | `IBannerContentProvider`, `IThemeProfileResolver`, `ITablerMediaClassMapper` | Phase 3 | Pattern helper | + +--- + +## 2) Host Integration Matrix + +### 2.1 Package matrix + +| Aspect | Shared Responsibilities (`Code311.Tabler.Mvc` + `Code311.Tabler.Razor`) | MVC-only (`Code311.Tabler.Mvc`) | Razor-only (`Code311.Tabler.Razor`) | +|---|---|---|---| +| Core registration | Register `Ui.Core`, `Tabler.Core`, `Tabler.Components`, `Tabler.Dashboard`, selected `Tabler.Widgets.*` | Same shared registration for MVC apps | Same shared registration for Razor Pages apps | +| Feedback services integration | Wire alert/toast/dialog/busy pipelines to request lifecycle | Action-result feedback adapters and MVC model-state bridge | Handler-result feedback adapters and Razor handler-state bridge | +| Loader/preloader integration | Register loader orchestration and preloader policy services | MVC filter hooks for action-start/action-complete loader transitions | Razor handler filters for page-handler loader transitions | +| Theme system integration | Request-time theme resolution and preference application | Controller/view-context theme helpers | PageModel/view-data theme helpers | +| Asset pipeline integration | Asset contributor registration, ordered manifests, deduplication | MVC view helper hooks for scoped asset injection | Razor layout/page helper hooks for scoped asset injection | +| Licensing integration | Startup validation + bounded runtime checks at integration points | MVC filter/convention integration points | Razor filter/convention integration points | +| Design neutrality guard | Keep semantic API surface; no Tabler token exposure outside internals | Same | Same | + +### 2.2 DI/service registration extensions + +- `AddCode311TablerMvc(...)` +- `AddCode311TablerRazor(...)` +- `AddCode311TablerDashboard(...)` +- `AddCode311TablerWidgetsDataTables(...)` +- `AddCode311TablerWidgetsCalendar(...)` +- `AddCode311TablerWidgetsCharts(...)` +- `AddCode311PersistenceEfCore(...)` (provider-agnostic configuration surface) +- `AddCode311Licensing(...)` + +### 2.3 Filters / conventions / helpers / base types + +| Package | Filters | Conventions | Helpers | Base Types | +|---|---|---|---|---| +| `Code311.Tabler.Mvc` | `Code311FeedbackActionFilter`, `Code311BusyTransitionFilter`, `Code311ThemeContextFilter` | Controller CRUD UX convention set, model-state to feedback convention, view asset convention | `IHtmlHelper` semantic component helpers, feedback helper facade, theme helper facade | `Code311ControllerBase`, `Code311ViewModelBase` | +| `Code311.Tabler.Razor` | `Code311PageFeedbackFilter`, `Code311BusyTransitionPageFilter`, `Code311ThemePageFilter` | Page folder UX conventions, handler feedback convention, page asset convention | `PageModel` semantic helpers, feedback helper facade, theme helper facade | `Code311PageModelBase`, `Code311RazorViewModelBase` | + +--- + +## 3) New / Updated Architecture Decisions + +| Decision | Context | Chosen Option | Reasoning | Consequence | +|---|---|---|---|---| +| DR-0010: Semantic vocabulary normalization | Matrix drift risk from inconsistent parameter names | Standardize component vocabulary around `tone`, `appearance`, `density`, `size`, `state`, `placement`, `layout`, `pinned` | Improves API predictability and cross-design-system portability | API review checklist must enforce vocabulary | +| DR-0011: Explicit internal dependency contracts | Prior matrix used vague dependency labels | Use named internal contracts/services per component line item | Clarifies package responsibilities and test seams | Requires maintaining contract catalog documentation | +| DR-0012: Explicit implementation type taxonomy | Ambiguous type descriptions reduced precision | Restrict to: TagHelper, ViewComponent, HTML helper extension, Service + render pattern | Better implementation planning and ownership clarity | Architecture reviews must reject vague type labels | +| DR-0013: Host adapter parity as governance rule | Risk of capability drift between MVC and Razor adapters | Define parity matrix for shared behaviors and stack-specific deltas | Preserves consistent developer experience | Requires parity tests and release gate checks | + +--- + +## 4) Approval Gate + +Architecture deliverables finalized for this phase: +1. Expanded Component Coverage Matrix +2. Host Integration Matrix +3. New architecture decisions + +Awaiting approval before any implementation work. diff --git a/global.json b/global.json new file mode 100644 index 0000000..82dabbb --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": true + } +} diff --git a/src/Code311.Host/.gitkeep b/src/Code311.Host/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Host/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Host/Code311.Host.csproj b/src/Code311.Host/Code311.Host.csproj new file mode 100644 index 0000000..4e118e2 --- /dev/null +++ b/src/Code311.Host/Code311.Host.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <RootNamespace>Code311.Host</RootNamespace> + <AssemblyName>Code311.Host</AssemblyName> + <Description>Hybrid MVC + Razor demo host for Code311 framework packages.</Description> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Code311.Ui.Core\Code311.Ui.Core.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Core\Code311.Tabler.Core.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Components\Code311.Tabler.Components.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Mvc\Code311.Tabler.Mvc.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Razor\Code311.Tabler.Razor.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Dashboard\Code311.Tabler.Dashboard.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Widgets.DataTables\Code311.Tabler.Widgets.DataTables.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Widgets.Calendar\Code311.Tabler.Widgets.Calendar.csproj" /> + <ProjectReference Include="..\Code311.Tabler.Widgets.Charts\Code311.Tabler.Widgets.Charts.csproj" /> + <ProjectReference Include="..\Code311.Persistence.EFCore\Code311.Persistence.EFCore.csproj" /> + <ProjectReference Include="..\Code311.Licensing\Code311.Licensing.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> + </ItemGroup> +</Project> diff --git a/src/Code311.Host/Controllers/ArchitectureController.cs b/src/Code311.Host/Controllers/ArchitectureController.cs new file mode 100644 index 0000000..4914859 --- /dev/null +++ b/src/Code311.Host/Controllers/ArchitectureController.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class ArchitectureController : Controller +{ + public IActionResult About() => View(); +} diff --git a/src/Code311.Host/Controllers/ComponentsDemoController.cs b/src/Code311.Host/Controllers/ComponentsDemoController.cs new file mode 100644 index 0000000..97633ff --- /dev/null +++ b/src/Code311.Host/Controllers/ComponentsDemoController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class ComponentsDemoController : Controller +{ + public IActionResult Forms() => View(); + public IActionResult Navigation() => View(); + public IActionResult Layout() => View(); + public IActionResult Feedback() => View(); + public IActionResult Data() => View(); + public IActionResult Media() => View(); +} diff --git a/src/Code311.Host/Controllers/DashboardController.cs b/src/Code311.Host/Controllers/DashboardController.cs new file mode 100644 index 0000000..8a66fce --- /dev/null +++ b/src/Code311.Host/Controllers/DashboardController.cs @@ -0,0 +1,15 @@ +using Code311.Host.Models; +using Code311.Licensing.Validation; +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class DashboardController(ILicenseFeatureGate featureGate) : Controller +{ + public async Task<IActionResult> Index(CancellationToken cancellationToken) + { + var check = await featureGate.CheckFeatureAsync("dashboard.advanced", cancellationToken); + var model = new DashboardDemoViewModel(check.IsAllowed, check.Reason); + return View(model); + } +} diff --git a/src/Code311.Host/Controllers/HomeController.cs b/src/Code311.Host/Controllers/HomeController.cs new file mode 100644 index 0000000..4fabf17 --- /dev/null +++ b/src/Code311.Host/Controllers/HomeController.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class HomeController : Controller +{ + public IActionResult Index() => View(); +} diff --git a/src/Code311.Host/Controllers/PreferencesController.cs b/src/Code311.Host/Controllers/PreferencesController.cs new file mode 100644 index 0000000..5b36c4e --- /dev/null +++ b/src/Code311.Host/Controllers/PreferencesController.cs @@ -0,0 +1,25 @@ +using Code311.Host.Models; +using Code311.Host.Services; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class PreferencesController(IPreferenceOrchestrator orchestrator) : Controller +{ + [HttpGet] + public async Task<IActionResult> Index(CancellationToken cancellationToken) + { + var current = await orchestrator.GetCurrentAsync(cancellationToken); + return View(new PreferencesPageViewModel(current, string.Empty)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task<IActionResult> Save(string theme, UiDensity density, int defaultPageSize, bool sidebarCollapsed, CancellationToken cancellationToken) + { + await orchestrator.SaveAsync(new PreferenceInputModel(theme, density, defaultPageSize, sidebarCollapsed), cancellationToken); + var current = await orchestrator.GetCurrentAsync(cancellationToken); + return View("Index", new PreferencesPageViewModel(current, "Preferences saved for demo-tenant/demo-user.")); + } +} diff --git a/src/Code311.Host/Controllers/WidgetsController.cs b/src/Code311.Host/Controllers/WidgetsController.cs new file mode 100644 index 0000000..52aed68 --- /dev/null +++ b/src/Code311.Host/Controllers/WidgetsController.cs @@ -0,0 +1,50 @@ +using Code311.Host.Models; +using Code311.Tabler.Mvc.Assets; +using Code311.Tabler.Widgets.Calendar.Options; +using Code311.Tabler.Widgets.Calendar.Widgets; +using Code311.Tabler.Widgets.Charts.Options; +using Code311.Tabler.Widgets.Charts.Widgets; +using Code311.Tabler.Widgets.DataTables.Options; +using Code311.Tabler.Widgets.DataTables.Widgets; +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Host.Controllers; + +public sealed class WidgetsController(ICode311AssetRequestStore assets) : Controller +{ + public IActionResult DataTables() + { + var widget = new DataTableWidgetSlot( + "widget.datatables.incidents", + "panel-body", + new DataTableWidgetOptionsBuilder().WithPageLength(25).EnableSearch(true).Build()); + + assets.AddWidgetAssets(widget); + var init = widget.CreateInitialization("datatable-demo"); + return View(new WidgetDemoViewModel("DataTables", init.ElementId, init.OptionsJson)); + } + + public IActionResult Calendar() + { + var widget = new CalendarWidgetSlot( + "widget.calendar.operations", + "panel-body", + new CalendarWidgetOptionsBuilder().WithInitialView("dayGridMonth").ShowWeekends(true).Build()); + + assets.AddWidgetAssets(widget); + var init = widget.CreateInitialization("calendar-demo"); + return View("WidgetPage", new WidgetDemoViewModel("Calendar", init.ElementId, init.OptionsJson)); + } + + public IActionResult Charts() + { + var widget = new ChartWidgetSlot( + "widget.charts.kpi", + "panel-body", + new ChartWidgetOptionsBuilder().WithType("line").ShowLegend(true).Build()); + + assets.AddWidgetAssets(widget); + var init = widget.CreateInitialization("charts-demo"); + return View("WidgetPage", new WidgetDemoViewModel("Charts", init.ElementId, init.OptionsJson)); + } +} diff --git a/src/Code311.Host/Models/HostViewModels.cs b/src/Code311.Host/Models/HostViewModels.cs new file mode 100644 index 0000000..55c1b71 --- /dev/null +++ b/src/Code311.Host/Models/HostViewModels.cs @@ -0,0 +1,12 @@ +using Code311.Licensing.Models; +using Code311.Ui.Abstractions.Preferences; + +namespace Code311.Host.Models; + +public sealed record DashboardDemoViewModel(bool AdvancedEnabled, string FeatureReason); + +public sealed record WidgetDemoViewModel(string WidgetName, string ElementId, string InitializationJson); + +public sealed record LicensingDiagnosticsViewModel(LicenseRuntimeStatus? Current, IReadOnlyList<LicenseRuntimeStatus> History); + +public sealed record PreferencesPageViewModel(UserUiPreference Current, string Message); diff --git a/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml b/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml new file mode 100644 index 0000000..753b262 --- /dev/null +++ b/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml @@ -0,0 +1,17 @@ +@page +@model Code311.Host.Pages.Diagnostics.LicensingModel + +<cd311-page title="Licensing Diagnostics"></cd311-page> + +<p>Current status: <strong>@Model.Diagnostics.Current?.Level</strong> - @Model.Diagnostics.Current?.Message</p> + +<h3>Status History</h3> +<ul> +@foreach (var item in Model.Diagnostics.History) +{ + <li>@item.OccurredAtUtc.ToString("u") | @item.Stage | @item.Level | @item.Code | @item.Message</li> +} +</ul> + +<h3>Runtime Feature Probe</h3> +<p>dashboard.advanced => <strong>@Model.FeatureCheck.Feature</strong> allowed: <strong>@Model.FeatureCheck.IsAllowed</strong> (@Model.FeatureCheck.Reason)</p> diff --git a/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml.cs b/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml.cs new file mode 100644 index 0000000..19afa18 --- /dev/null +++ b/src/Code311.Host/Pages/Diagnostics/Licensing.cshtml.cs @@ -0,0 +1,19 @@ +using Code311.Host.Models; +using Code311.Licensing.Diagnostics; +using Code311.Licensing.Validation; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Code311.Host.Pages.Diagnostics; + +public sealed class LicensingModel(ILicensingStatusReporter reporter, ILicenseFeatureGate featureGate) : PageModel +{ + public LicensingDiagnosticsViewModel Diagnostics { get; private set; } = new(null, []); + public Code311.Licensing.Models.LicenseFeatureCheckResult FeatureCheck { get; private set; } = + new(false, "dashboard.advanced", Code311.Licensing.Models.LicenseStatusLevel.Error, "Not evaluated", null); + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + FeatureCheck = await featureGate.CheckFeatureAsync("dashboard.advanced", cancellationToken); + Diagnostics = new LicensingDiagnosticsViewModel(reporter.Current, reporter.GetHistory()); + } +} diff --git a/src/Code311.Host/Pages/_ViewImports.cshtml b/src/Code311.Host/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..e8e2908 --- /dev/null +++ b/src/Code311.Host/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@namespace Code311.Host.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Code311.Tabler.Components diff --git a/src/Code311.Host/Pages/_ViewStart.cshtml b/src/Code311.Host/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..a0f2b94 --- /dev/null +++ b/src/Code311.Host/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "/Views/Shared/_Layout.cshtml"; +} diff --git a/src/Code311.Host/Program.cs b/src/Code311.Host/Program.cs new file mode 100644 index 0000000..87c52a6 --- /dev/null +++ b/src/Code311.Host/Program.cs @@ -0,0 +1,93 @@ +using Code311.Host.Services; +using Code311.Licensing.DependencyInjection; +using Code311.Licensing.Models; +using Code311.Licensing.Validation; +using Code311.Persistence.EFCore; +using Code311.Persistence.EFCore.DependencyInjection; +using Code311.Tabler.Dashboard.DependencyInjection; +using Code311.Tabler.Mvc.DependencyInjection; +using Code311.Tabler.Razor.DependencyInjection; +using Code311.Tabler.Widgets.Calendar.DependencyInjection; +using Code311.Tabler.Widgets.Charts.DependencyInjection; +using Code311.Tabler.Widgets.DataTables.DependencyInjection; +using Code311.Ui.Abstractions.Preferences; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllersWithViews(); +builder.Services.AddRazorPages(); + +// Explicit framework package registration for demonstration clarity. +builder.Services.AddCode311TablerMvc(); +builder.Services.AddCode311TablerRazor(); +builder.Services.AddCode311TablerDashboard(); +builder.Services.AddCode311TablerDataTablesWidgets(); +builder.Services.AddCode311TablerCalendarWidgets(); +builder.Services.AddCode311TablerChartWidgets(); + +// Host-local provider choice: SQLite for practical demo persistence. +var connectionString = builder.Configuration.GetConnectionString("Code311Demo") ?? "Data Source=App_Data/code311-host-demo.db"; +Directory.CreateDirectory("App_Data"); +builder.Services.AddCode311PersistenceEfCore(options => options.UseSqlite(connectionString)); + +builder.Services.AddCode311Licensing(options => +{ + options.RequireValidLicenseAtStartup = true; + options.ExpiryWarningWindowDays = 30; +}); +builder.Services.AddCode311InMemoryLicenseSource(new Code311License +{ + LicenseId = "demo-license-001", + CustomerName = "Code311 Demo Host", + Plan = "demo", + NotBeforeUtc = DateTimeOffset.UtcNow.AddDays(-1), + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(90), + Features = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "dashboard.basic", + "dashboard.advanced", + "widgets.datatables", + "widgets.calendar", + "widgets.charts" + } +}); + +builder.Services.AddScoped<IDemoUserContext, DemoUserContext>(); +builder.Services.AddScoped<IPreferenceOrchestrator, PreferenceOrchestrator>(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<Code311PreferenceDbContext>(); + await db.Database.EnsureCreatedAsync(); + + var startupValidator = scope.ServiceProvider.GetRequiredService<IStartupLicenseValidator>(); + try + { + await startupValidator.ValidateAtStartupAsync(); + } + catch (InvalidOperationException ex) + { + throw new HostStartupLicenseException("Code311.Host startup license validation failed.", ex); + } +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); +} + +app.UseStaticFiles(); +app.UseRouting(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +app.MapRazorPages(); + +await app.RunAsync(); + +public partial class Program; diff --git a/src/Code311.Host/Services/DemoUserContext.cs b/src/Code311.Host/Services/DemoUserContext.cs new file mode 100644 index 0000000..50db2c8 --- /dev/null +++ b/src/Code311.Host/Services/DemoUserContext.cs @@ -0,0 +1,13 @@ +namespace Code311.Host.Services; + +public interface IDemoUserContext +{ + string TenantId { get; } + string UserId { get; } +} + +public sealed class DemoUserContext : IDemoUserContext +{ + public string TenantId => "demo-tenant"; + public string UserId => "demo-user"; +} diff --git a/src/Code311.Host/Services/HostStartupLicenseException.cs b/src/Code311.Host/Services/HostStartupLicenseException.cs new file mode 100644 index 0000000..f9da558 --- /dev/null +++ b/src/Code311.Host/Services/HostStartupLicenseException.cs @@ -0,0 +1,6 @@ +namespace Code311.Host.Services; + +/// <summary> +/// Represents host-local startup failure due to licensing validation. +/// </summary> +public sealed class HostStartupLicenseException(string message, Exception innerException) : Exception(message, innerException); diff --git a/src/Code311.Host/Services/PreferenceOrchestrator.cs b/src/Code311.Host/Services/PreferenceOrchestrator.cs new file mode 100644 index 0000000..f554b54 --- /dev/null +++ b/src/Code311.Host/Services/PreferenceOrchestrator.cs @@ -0,0 +1,48 @@ +using Code311.Ui.Abstractions.Preferences; +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Host.Services; + +public sealed record PreferenceInputModel(string Theme, UiDensity Density, int DefaultPageSize, bool SidebarCollapsed); + +public interface IPreferenceOrchestrator +{ + Task<UserUiPreference> GetCurrentAsync(CancellationToken cancellationToken = default); + Task SaveAsync(PreferenceInputModel input, CancellationToken cancellationToken = default); +} + +public sealed class PreferenceOrchestrator(IUserUiPreferenceStore store, IDemoUserContext userContext) : IPreferenceOrchestrator +{ + public async Task<UserUiPreference> GetCurrentAsync(CancellationToken cancellationToken = default) + { + var existing = await store.GetAsync(userContext.TenantId, userContext.UserId, cancellationToken); + return existing ?? new UserUiPreference + { + TenantId = userContext.TenantId, + UserId = userContext.UserId, + Theme = "default", + Density = UiDensity.Comfortable, + DefaultPageSize = 25, + SidebarCollapsed = false, + Language = "en-US", + TimeZone = "UTC" + }; + } + + public async Task SaveAsync(PreferenceInputModel input, CancellationToken cancellationToken = default) + { + var model = new UserUiPreference + { + TenantId = userContext.TenantId, + UserId = userContext.UserId, + Theme = input.Theme, + Density = input.Density, + DefaultPageSize = input.DefaultPageSize, + SidebarCollapsed = input.SidebarCollapsed, + Language = "en-US", + TimeZone = "UTC" + }; + + await store.UpsertAsync(model, cancellationToken); + } +} diff --git a/src/Code311.Host/Views/Architecture/About.cshtml b/src/Code311.Host/Views/Architecture/About.cshtml new file mode 100644 index 0000000..c9201a6 --- /dev/null +++ b/src/Code311.Host/Views/Architecture/About.cshtml @@ -0,0 +1,7 @@ +<cd311-page title="Architecture / About"></cd311-page> +<p>Code311.Host is the official hybrid MVC + Razor integration portal for framework showcase and documentation alignment.</p> +<ul> + <li>Framework packages remain isolated from host concerns.</li> + <li>Host owns provider binding (SQLite demo persistence).</li> + <li>Host owns startup licensing invocation and diagnostics exposure.</li> +</ul> diff --git a/src/Code311.Host/Views/ComponentsDemo/Data.cshtml b/src/Code311.Host/Views/ComponentsDemo/Data.cshtml new file mode 100644 index 0000000..8daedb0 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Data.cshtml @@ -0,0 +1,2 @@ +<cd311-page title="Components / Data"></cd311-page> +<p>Data primitives are showcased in Widgets/DataTables page with runtime widget bootstrapping.</p> diff --git a/src/Code311.Host/Views/ComponentsDemo/Feedback.cshtml b/src/Code311.Host/Views/ComponentsDemo/Feedback.cshtml new file mode 100644 index 0000000..ed20767 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Feedback.cshtml @@ -0,0 +1,2 @@ +<cd311-page title="Components / Feedback"></cd311-page> +<p>Feedback channel and request stores are wired by Tabler MVC adapter integration.</p> diff --git a/src/Code311.Host/Views/ComponentsDemo/Forms.cshtml b/src/Code311.Host/Views/ComponentsDemo/Forms.cshtml new file mode 100644 index 0000000..b00d6b4 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Forms.cshtml @@ -0,0 +1,11 @@ +<cd311-page title="Components / Forms"></cd311-page> +<cd311-input field="email" label="Email"></cd311-input> +<cd311-textarea field="notes" rows="4"></cd311-textarea> +<cd311-switch field="alerts" enabled="true"></cd311-switch> +<p> + <a asp-controller="ComponentsDemo" asp-action="Navigation">Navigation</a> | + <a asp-controller="ComponentsDemo" asp-action="Layout">Layout</a> | + <a asp-controller="ComponentsDemo" asp-action="Feedback">Feedback</a> | + <a asp-controller="ComponentsDemo" asp-action="Data">Data</a> | + <a asp-controller="ComponentsDemo" asp-action="Media">Media</a> +</p> diff --git a/src/Code311.Host/Views/ComponentsDemo/Layout.cshtml b/src/Code311.Host/Views/ComponentsDemo/Layout.cshtml new file mode 100644 index 0000000..1bd0273 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Layout.cshtml @@ -0,0 +1,5 @@ +<cd311-page title="Components / Layout"></cd311-page> +<cd311-grid columns="2"> + <cd311-card title="Card A" tone="Info">Layout card A</cd311-card> + <cd311-card title="Card B" tone="Success">Layout card B</cd311-card> +</cd311-grid> diff --git a/src/Code311.Host/Views/ComponentsDemo/Media.cshtml b/src/Code311.Host/Views/ComponentsDemo/Media.cshtml new file mode 100644 index 0000000..13056b0 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Media.cshtml @@ -0,0 +1,2 @@ +<cd311-page title="Components / Media"></cd311-page> +<p>Media components can be introduced similarly; this page exists for section completeness.</p> diff --git a/src/Code311.Host/Views/ComponentsDemo/Navigation.cshtml b/src/Code311.Host/Views/ComponentsDemo/Navigation.cshtml new file mode 100644 index 0000000..e605878 --- /dev/null +++ b/src/Code311.Host/Views/ComponentsDemo/Navigation.cshtml @@ -0,0 +1,2 @@ +<cd311-page title="Components / Navigation"></cd311-page> +<p>Navigation semantics are demonstrated via host menu and component package registration.</p> diff --git a/src/Code311.Host/Views/Dashboard/Index.cshtml b/src/Code311.Host/Views/Dashboard/Index.cshtml new file mode 100644 index 0000000..94b824e --- /dev/null +++ b/src/Code311.Host/Views/Dashboard/Index.cshtml @@ -0,0 +1,15 @@ +@model DashboardDemoViewModel +@using Code311.Tabler.Dashboard.Models +@using Code311.Ui.Abstractions.Semantics + +<cd311-page title="Dashboard"></cd311-page> +@{ + var panel = new DashboardPanelModel( + PanelKey: "panel-demo", + Title: "Operational KPI", + BodyHtml: Model.AdvancedEnabled ? "Advanced dashboard feature is licensed." : "Advanced feature unavailable.", + Tone: Model.AdvancedEnabled ? UiTone.Success : UiTone.Warning); +} + +<div>@await Component.InvokeAsync("Cd311MetricCard", panel)</div> +<p>Feature gate result: <strong>@Model.FeatureReason</strong></p> diff --git a/src/Code311.Host/Views/Home/Index.cshtml b/src/Code311.Host/Views/Home/Index.cshtml new file mode 100644 index 0000000..48d465f --- /dev/null +++ b/src/Code311.Host/Views/Home/Index.cshtml @@ -0,0 +1,12 @@ +@{ + ViewData["Title"] = "Home"; +} + +<cd311-page title="Home"></cd311-page> +<p>This is the official hybrid MVC + Razor demo host for Code311.</p> +<ul> + <li>MVC controllers and views for Components, Dashboard, Widgets, Preferences, Architecture.</li> + <li>Razor Page for Licensing Diagnostics.</li> + <li>SQLite-backed persisted UI preferences for demo tenant/user.</li> + <li>Explicit startup license validation and runtime feature gate checks.</li> +</ul> diff --git a/src/Code311.Host/Views/Preferences/Index.cshtml b/src/Code311.Host/Views/Preferences/Index.cshtml new file mode 100644 index 0000000..3a8dde2 --- /dev/null +++ b/src/Code311.Host/Views/Preferences/Index.cshtml @@ -0,0 +1,24 @@ +@model PreferencesPageViewModel + +<cd311-page title="Preferences / Theme"></cd311-page> +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + <p><strong>@Model.Message</strong></p> +} + +<form asp-controller="Preferences" asp-action="Save" method="post"> + @Html.AntiForgeryToken() + <label>Theme <input type="text" name="theme" value="@Model.Current.Theme" /></label><br /> + <label>Density + <select name="density"> + <option value="Comfortable" selected="@(Model.Current.Density == UiDensity.Comfortable)">Comfortable</option> + <option value="Compact" selected="@(Model.Current.Density == UiDensity.Compact)">Compact</option> + <option value="Spacious" selected="@(Model.Current.Density == UiDensity.Spacious)">Spacious</option> + </select> + </label><br /> + <label>Default Page Size <input type="number" name="defaultPageSize" value="@Model.Current.DefaultPageSize" min="5" max="200" /></label><br /> + <label>Sidebar Collapsed <input type="checkbox" name="sidebarCollapsed" value="true" checked="@Model.Current.SidebarCollapsed" /></label><br /> + <button type="submit">Save Preferences</button> +</form> + +<p>Current persisted scope: tenant <code>@Model.Current.TenantId</code>, user <code>@Model.Current.UserId</code>.</p> diff --git a/src/Code311.Host/Views/Shared/_Layout.cshtml b/src/Code311.Host/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..52b6d90 --- /dev/null +++ b/src/Code311.Host/Views/Shared/_Layout.cshtml @@ -0,0 +1,25 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Code311.Host Demo + + + +
+
+ @RenderBody() +
+
+ + + diff --git a/src/Code311.Host/Views/Widgets/DataTables.cshtml b/src/Code311.Host/Views/Widgets/DataTables.cshtml new file mode 100644 index 0000000..b82451f --- /dev/null +++ b/src/Code311.Host/Views/Widgets/DataTables.cshtml @@ -0,0 +1,8 @@ +@model WidgetDemoViewModel + +
DataTables widget mount point
+
@Model.InitializationJson
+

+ Calendar | + Charts +

diff --git a/src/Code311.Host/Views/Widgets/WidgetPage.cshtml b/src/Code311.Host/Views/Widgets/WidgetPage.cshtml new file mode 100644 index 0000000..15de5a8 --- /dev/null +++ b/src/Code311.Host/Views/Widgets/WidgetPage.cshtml @@ -0,0 +1,9 @@ +@model WidgetDemoViewModel + +
@Model.WidgetName widget mount point
+
@Model.InitializationJson
+

+ DataTables | + Calendar | + Charts +

diff --git a/src/Code311.Host/Views/_ViewImports.cshtml b/src/Code311.Host/Views/_ViewImports.cshtml new file mode 100644 index 0000000..77add13 --- /dev/null +++ b/src/Code311.Host/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using Code311.Host +@using Code311.Host.Models +@using Code311.Ui.Abstractions.Semantics +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Code311.Tabler.Components diff --git a/src/Code311.Host/Views/_ViewStart.cshtml b/src/Code311.Host/Views/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/Code311.Host/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Code311.Host/appsettings.json b/src/Code311.Host/appsettings.json new file mode 100644 index 0000000..94f8d3f --- /dev/null +++ b/src/Code311.Host/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "Code311Demo": "Data Source=App_Data/code311-host-demo.db" + } +} diff --git a/src/Code311.Licensing/.gitkeep b/src/Code311.Licensing/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Licensing/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Licensing/Code311.Licensing.csproj b/src/Code311.Licensing/Code311.Licensing.csproj new file mode 100644 index 0000000..dca7175 --- /dev/null +++ b/src/Code311.Licensing/Code311.Licensing.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Code311.Licensing + Code311.Licensing + Hybrid licensing runtime for startup validation and bounded feature checks. + Code311.Licensing + + + + + + diff --git a/src/Code311.Licensing/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Licensing/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c900629 --- /dev/null +++ b/src/Code311.Licensing/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using Code311.Licensing.Diagnostics; +using Code311.Licensing.Models; +using Code311.Licensing.Sources; +using Code311.Licensing.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Code311.Licensing.DependencyInjection; + +/// +/// Provides registration helpers for Code311 licensing services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Code311 licensing services and an environment-variable-based source. + /// + public static IServiceCollection AddCode311Licensing( + this IServiceCollection services, + Action? configure = null, + string environmentVariableName = "CODE311_LICENSE_JSON") + { + ArgumentNullException.ThrowIfNull(services); + + var options = new LicensingOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(options); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(_ => new EnvironmentVariableLicenseSource(environmentVariableName)); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } + + /// + /// Replaces the registered source with an in-memory payload. + /// + public static IServiceCollection AddCode311InMemoryLicenseSource(this IServiceCollection services, Code311License? license) + { + ArgumentNullException.ThrowIfNull(services); + + services.Replace(ServiceDescriptor.Singleton(_ => new InMemoryLicenseSource(license))); + return services; + } +} diff --git a/src/Code311.Licensing/Diagnostics/LicensingDiagnostics.cs b/src/Code311.Licensing/Diagnostics/LicensingDiagnostics.cs new file mode 100644 index 0000000..7a7a0e1 --- /dev/null +++ b/src/Code311.Licensing/Diagnostics/LicensingDiagnostics.cs @@ -0,0 +1,29 @@ +using Code311.Licensing.Models; + +namespace Code311.Licensing.Diagnostics; + +/// +/// Reports and exposes licensing runtime status. +/// +public interface ILicensingStatusReporter +{ + void Report(LicenseRuntimeStatus status); + LicenseRuntimeStatus? Current { get; } + IReadOnlyList GetHistory(); +} + +public sealed class InMemoryLicensingStatusReporter : ILicensingStatusReporter +{ + private readonly List _history = []; + + public LicenseRuntimeStatus? Current { get; private set; } + + public void Report(LicenseRuntimeStatus status) + { + ArgumentNullException.ThrowIfNull(status); + Current = status; + _history.Add(status); + } + + public IReadOnlyList GetHistory() => _history; +} diff --git a/src/Code311.Licensing/Models/LicenseModels.cs b/src/Code311.Licensing/Models/LicenseModels.cs new file mode 100644 index 0000000..a8a70d4 --- /dev/null +++ b/src/Code311.Licensing/Models/LicenseModels.cs @@ -0,0 +1,49 @@ +namespace Code311.Licensing.Models; + +/// +/// Represents a parsed Code311 license payload. +/// +public sealed record Code311License +{ + public required string LicenseId { get; init; } + public required string CustomerName { get; init; } + public string Plan { get; init; } = "standard"; + public DateTimeOffset? NotBeforeUtc { get; init; } + public DateTimeOffset? ExpiresUtc { get; init; } + public IReadOnlySet Features { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); +} + +/// +/// Configuration options for licensing behavior. +/// +public sealed class LicensingOptions +{ + /// + /// Indicates whether startup validation is mandatory. + /// + public bool RequireValidLicenseAtStartup { get; set; } = true; + + /// + /// Number of days before expiry where validation emits warning status. + /// + public int ExpiryWarningWindowDays { get; set; } = 14; +} + +/// +/// Indicates licensing status severity at runtime. +/// +public enum LicenseStatusLevel +{ + Valid, + Warning, + Error +} + +/// +/// Describes lifecycle stage where a license status was observed. +/// +public enum LicenseCheckStage +{ + Startup, + RuntimeFeature +} diff --git a/src/Code311.Licensing/Models/LicenseStatusModels.cs b/src/Code311.Licensing/Models/LicenseStatusModels.cs new file mode 100644 index 0000000..8f1e2c9 --- /dev/null +++ b/src/Code311.Licensing/Models/LicenseStatusModels.cs @@ -0,0 +1,35 @@ +namespace Code311.Licensing.Models; + +/// +/// Represents a machine-readable status item produced by licensing operations. +/// +public sealed record LicenseStatusItem(LicenseStatusLevel Level, string Code, string Message); + +/// +/// Represents the result of license validation. +/// +public sealed record LicenseValidationResult( + bool IsValid, + LicenseStatusLevel OverallLevel, + IReadOnlyList Items, + Code311License? License); + +/// +/// Represents a bounded runtime feature check result. +/// +public sealed record LicenseFeatureCheckResult( + bool IsAllowed, + string Feature, + LicenseStatusLevel Level, + string Reason, + Code311License? License); + +/// +/// Represents reported runtime status suitable for host diagnostics surfaces. +/// +public sealed record LicenseRuntimeStatus( + DateTimeOffset OccurredAtUtc, + LicenseCheckStage Stage, + LicenseStatusLevel Level, + string Code, + string Message); diff --git a/src/Code311.Licensing/Sources/LicenseSources.cs b/src/Code311.Licensing/Sources/LicenseSources.cs new file mode 100644 index 0000000..0921bed --- /dev/null +++ b/src/Code311.Licensing/Sources/LicenseSources.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Code311.Licensing.Models; + +namespace Code311.Licensing.Sources; + +/// +/// Resolves a license payload from a configured source. +/// +public interface ILicenseSource +{ + Task GetLicenseAsync(CancellationToken cancellationToken = default); +} + +/// +/// In-memory source suitable for tests and deterministic bootstrapping. +/// +public sealed class InMemoryLicenseSource(Code311License? license) : ILicenseSource +{ + public Task GetLicenseAsync(CancellationToken cancellationToken = default) + => Task.FromResult(license); +} + +/// +/// Reads a license payload from an environment variable containing JSON. +/// +public sealed class EnvironmentVariableLicenseSource(string variableName) : ILicenseSource +{ + public Task GetLicenseAsync(CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(variableName); + + var payload = Environment.GetEnvironmentVariable(variableName); + if (string.IsNullOrWhiteSpace(payload)) + { + return Task.FromResult(null); + } + + var model = JsonSerializer.Deserialize(payload); + return Task.FromResult(model); + } +} diff --git a/src/Code311.Licensing/Validation/LicenseValidationServices.cs b/src/Code311.Licensing/Validation/LicenseValidationServices.cs new file mode 100644 index 0000000..2a3f626 --- /dev/null +++ b/src/Code311.Licensing/Validation/LicenseValidationServices.cs @@ -0,0 +1,129 @@ +using Code311.Licensing.Diagnostics; +using Code311.Licensing.Models; +using Code311.Licensing.Sources; + +namespace Code311.Licensing.Validation; + +/// +/// Validates license payload semantics. +/// +public interface ILicenseValidator +{ + LicenseValidationResult Validate(Code311License? license, DateTimeOffset nowUtc, LicensingOptions options); +} + +/// +/// Provides explicit startup validation flow. +/// +public interface IStartupLicenseValidator +{ + Task ValidateAtStartupAsync(CancellationToken cancellationToken = default); +} + +/// +/// Provides bounded runtime feature-level checks for integration points. +/// +public interface ILicenseFeatureGate +{ + Task CheckFeatureAsync(string feature, CancellationToken cancellationToken = default); +} + +public sealed class DefaultLicenseValidator : ILicenseValidator +{ + public LicenseValidationResult Validate(Code311License? license, DateTimeOffset nowUtc, LicensingOptions options) + { + var items = new List(); + + if (license is null) + { + items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.missing", "No license payload could be resolved.")); + return new LicenseValidationResult(false, LicenseStatusLevel.Error, items, null); + } + + if (license.NotBeforeUtc.HasValue && nowUtc < license.NotBeforeUtc.Value) + { + items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.not_before", "License is not active yet.")); + } + + if (license.ExpiresUtc.HasValue) + { + if (nowUtc >= license.ExpiresUtc.Value) + { + items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.expired", "License has expired.")); + } + else if (nowUtc >= license.ExpiresUtc.Value.AddDays(-Math.Abs(options.ExpiryWarningWindowDays))) + { + items.Add(new LicenseStatusItem(LicenseStatusLevel.Warning, "license.expiring_soon", "License is approaching expiry.")); + } + } + + if (items.All(x => x.Level != LicenseStatusLevel.Error)) + { + items.Add(new LicenseStatusItem(LicenseStatusLevel.Valid, "license.valid", "License is valid.")); + } + + var overall = items.Any(x => x.Level == LicenseStatusLevel.Error) + ? LicenseStatusLevel.Error + : items.Any(x => x.Level == LicenseStatusLevel.Warning) + ? LicenseStatusLevel.Warning + : LicenseStatusLevel.Valid; + + return new LicenseValidationResult(overall != LicenseStatusLevel.Error, overall, items, license); + } +} + +public sealed class StartupLicenseValidator( + ILicenseSource source, + ILicenseValidator validator, + ILicensingStatusReporter reporter, + LicensingOptions options) : IStartupLicenseValidator +{ + public async Task ValidateAtStartupAsync(CancellationToken cancellationToken = default) + { + var license = await source.GetLicenseAsync(cancellationToken).ConfigureAwait(false); + var result = validator.Validate(license, DateTimeOffset.UtcNow, options); + + var top = result.Items.FirstOrDefault() ?? new LicenseStatusItem(result.OverallLevel, "license.status", "License status generated."); + reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.Startup, result.OverallLevel, top.Code, top.Message)); + + if (options.RequireValidLicenseAtStartup && !result.IsValid) + { + throw new InvalidOperationException("Code311 startup license validation failed."); + } + + return result; + } +} + +public sealed class LicenseFeatureGate( + ILicenseSource source, + ILicenseValidator validator, + ILicensingStatusReporter reporter, + LicensingOptions options) : ILicenseFeatureGate +{ + public async Task CheckFeatureAsync(string feature, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(feature); + + var license = await source.GetLicenseAsync(cancellationToken).ConfigureAwait(false); + var validation = validator.Validate(license, DateTimeOffset.UtcNow, options); + + if (!validation.IsValid || validation.License is null) + { + var denied = new LicenseFeatureCheckResult(false, feature, LicenseStatusLevel.Error, "License invalid for feature check.", license); + reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, denied.Level, "feature.denied.invalid_license", denied.Reason)); + return denied; + } + + if (!validation.License.Features.Contains(feature)) + { + var denied = new LicenseFeatureCheckResult(false, feature, LicenseStatusLevel.Warning, "Feature not covered by current license.", validation.License); + reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, denied.Level, "feature.denied.not_licensed", denied.Reason)); + return denied; + } + + var allowed = new LicenseFeatureCheckResult(true, feature, validation.OverallLevel, "Feature is licensed.", validation.License); + reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, allowed.Level, "feature.allowed", allowed.Reason)); + return allowed; + } +} diff --git a/src/Code311.Persistence.EFCore/.gitkeep b/src/Code311.Persistence.EFCore/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Persistence.EFCore/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Persistence.EFCore/Code311.Persistence.EFCore.csproj b/src/Code311.Persistence.EFCore/Code311.Persistence.EFCore.csproj new file mode 100644 index 0000000..dab7ebc --- /dev/null +++ b/src/Code311.Persistence.EFCore/Code311.Persistence.EFCore.csproj @@ -0,0 +1,19 @@ + + + net10.0 + Code311.Persistence.EFCore + Code311.Persistence.EFCore + Provider-agnostic EF Core persistence for Code311 UI preference storage. + Code311.Persistence.EFCore + + + + + + + + + + + + diff --git a/src/Code311.Persistence.EFCore/Code311PreferenceDbContext.cs b/src/Code311.Persistence.EFCore/Code311PreferenceDbContext.cs new file mode 100644 index 0000000..1ada373 --- /dev/null +++ b/src/Code311.Persistence.EFCore/Code311PreferenceDbContext.cs @@ -0,0 +1,19 @@ +using Code311.Persistence.EFCore.Entities; +using Code311.Persistence.EFCore.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace Code311.Persistence.EFCore; + +/// +/// Default EF Core DbContext for Code311 persistence features. +/// +public sealed class Code311PreferenceDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet UserUiPreferences => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyCode311PreferenceStorage(); + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Code311.Persistence.EFCore/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Persistence.EFCore/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d45cbe5 --- /dev/null +++ b/src/Code311.Persistence.EFCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Code311.Persistence.EFCore.Stores; +using Code311.Ui.Abstractions.Preferences; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Code311.Persistence.EFCore.DependencyInjection; + +/// +/// Provides provider-agnostic service registration for Code311 EF Core persistence. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the default Code311 preference DbContext and EF-based preference store. + /// + /// The service collection. + /// Provider-specific DbContext options configuration. + public static IServiceCollection AddCode311PersistenceEfCore( + this IServiceCollection services, + Action configureDbContext) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureDbContext); + + services.AddDbContext(configureDbContext); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/Code311.Persistence.EFCore/Entities/UserUiPreferenceEntity.cs b/src/Code311.Persistence.EFCore/Entities/UserUiPreferenceEntity.cs new file mode 100644 index 0000000..123a32e --- /dev/null +++ b/src/Code311.Persistence.EFCore/Entities/UserUiPreferenceEntity.cs @@ -0,0 +1,19 @@ +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Persistence.EFCore.Entities; + +/// +/// EF Core persistence model for a tenant-scoped user UI preference record. +/// +public sealed class UserUiPreferenceEntity +{ + public required string TenantId { get; set; } + public required string UserId { get; set; } + public required string Theme { get; set; } + public UiDensity Density { get; set; } + public bool SidebarCollapsed { get; set; } + public int DefaultPageSize { get; set; } + public string Language { get; set; } = "en"; + public string TimeZone { get; set; } = "UTC"; + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/Code311.Persistence.EFCore/Extensions/ModelBuilderExtensions.cs b/src/Code311.Persistence.EFCore/Extensions/ModelBuilderExtensions.cs new file mode 100644 index 0000000..2139c74 --- /dev/null +++ b/src/Code311.Persistence.EFCore/Extensions/ModelBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Code311.Persistence.EFCore.Mapping; +using Microsoft.EntityFrameworkCore; + +namespace Code311.Persistence.EFCore.Extensions; + +/// +/// Provides provider-agnostic model configuration helpers for Code311 preference entities. +/// +public static class ModelBuilderExtensions +{ + /// + /// Applies Code311 UI preference persistence mappings to a model builder. + /// + public static ModelBuilder ApplyCode311PreferenceStorage(this ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.ApplyConfiguration(new UserUiPreferenceEntityConfiguration()); + return modelBuilder; + } +} diff --git a/src/Code311.Persistence.EFCore/Mapping/UserUiPreferenceEntityConfiguration.cs b/src/Code311.Persistence.EFCore/Mapping/UserUiPreferenceEntityConfiguration.cs new file mode 100644 index 0000000..5a56d89 --- /dev/null +++ b/src/Code311.Persistence.EFCore/Mapping/UserUiPreferenceEntityConfiguration.cs @@ -0,0 +1,46 @@ +using Code311.Persistence.EFCore.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Code311.Persistence.EFCore.Mapping; + +internal sealed class UserUiPreferenceEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserUiPreferences"); + + builder.HasKey(x => new { x.TenantId, x.UserId }); + + builder.Property(x => x.TenantId) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(x => x.UserId) + .HasMaxLength(256) + .IsRequired(); + + builder.Property(x => x.Theme) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(x => x.Density) + .HasConversion() + .HasMaxLength(32) + .IsRequired(); + + builder.Property(x => x.DefaultPageSize) + .IsRequired(); + + builder.Property(x => x.Language) + .HasMaxLength(32) + .IsRequired(); + + builder.Property(x => x.TimeZone) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(x => x.UpdatedAt) + .IsRequired(); + } +} diff --git a/src/Code311.Persistence.EFCore/Stores/EfCoreUserUiPreferenceStore.cs b/src/Code311.Persistence.EFCore/Stores/EfCoreUserUiPreferenceStore.cs new file mode 100644 index 0000000..2d43378 --- /dev/null +++ b/src/Code311.Persistence.EFCore/Stores/EfCoreUserUiPreferenceStore.cs @@ -0,0 +1,83 @@ +using Code311.Persistence.EFCore.Entities; +using Code311.Ui.Abstractions.Preferences; +using Microsoft.EntityFrameworkCore; + +namespace Code311.Persistence.EFCore.Stores; + +/// +/// Provider-agnostic EF Core implementation of . +/// +public sealed class EfCoreUserUiPreferenceStore(Code311PreferenceDbContext dbContext) : IUserUiPreferenceStore +{ + public async Task GetAsync(string tenantId, string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + var entity = await dbContext.UserUiPreferences + .AsNoTracking() + .SingleOrDefaultAsync(x => x.TenantId == tenantId && x.UserId == userId, cancellationToken) + .ConfigureAwait(false); + + return entity is null ? null : ToModel(entity); + } + + public async Task UpsertAsync(UserUiPreference preference, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(preference); + ArgumentException.ThrowIfNullOrWhiteSpace(preference.TenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(preference.UserId); + ArgumentException.ThrowIfNullOrWhiteSpace(preference.Theme); + + var existing = await dbContext.UserUiPreferences + .SingleOrDefaultAsync(x => x.TenantId == preference.TenantId && x.UserId == preference.UserId, cancellationToken) + .ConfigureAwait(false); + + var utcNow = DateTimeOffset.UtcNow; + + if (existing is null) + { + dbContext.UserUiPreferences.Add(ToEntity(preference, utcNow)); + } + else + { + existing.Theme = preference.Theme; + existing.Density = preference.Density; + existing.SidebarCollapsed = preference.SidebarCollapsed; + existing.DefaultPageSize = preference.DefaultPageSize; + existing.Language = preference.Language; + existing.TimeZone = preference.TimeZone; + existing.UpdatedAt = utcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private static UserUiPreference ToModel(UserUiPreferenceEntity entity) + => new() + { + TenantId = entity.TenantId, + UserId = entity.UserId, + Theme = entity.Theme, + Density = entity.Density, + SidebarCollapsed = entity.SidebarCollapsed, + DefaultPageSize = entity.DefaultPageSize, + Language = entity.Language, + TimeZone = entity.TimeZone, + UpdatedAt = entity.UpdatedAt + }; + + private static UserUiPreferenceEntity ToEntity(UserUiPreference model, DateTimeOffset utcNow) + => new() + { + TenantId = model.TenantId, + UserId = model.UserId, + Theme = model.Theme, + Density = model.Density, + SidebarCollapsed = model.SidebarCollapsed, + DefaultPageSize = model.DefaultPageSize, + Language = model.Language, + TimeZone = model.TimeZone, + UpdatedAt = utcNow + }; +} diff --git a/src/Code311.Tabler.Components/.gitkeep b/src/Code311.Tabler.Components/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Components/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Components/Code311.Tabler.Components.csproj b/src/Code311.Tabler.Components/Code311.Tabler.Components.csproj new file mode 100644 index 0000000..8a748e5 --- /dev/null +++ b/src/Code311.Tabler.Components/Code311.Tabler.Components.csproj @@ -0,0 +1,17 @@ + + + net10.0 + Code311.Tabler.Components + Code311.Tabler.Components + Tabler component primitives for Code311 semantic UI APIs. + Code311.Tabler.Components + true + + + + + + + + + diff --git a/src/Code311.Tabler.Components/Common/ComponentModels.cs b/src/Code311.Tabler.Components/Common/ComponentModels.cs new file mode 100644 index 0000000..2dcec00 --- /dev/null +++ b/src/Code311.Tabler.Components/Common/ComponentModels.cs @@ -0,0 +1,33 @@ +namespace Code311.Tabler.Components.Common; + +/// +/// Represents a semantic text/value option item. +/// +/// +/// This item is used by form and navigation components without exposing framework-specific option models. +/// +public sealed record Cd311OptionItem(string Value, string Text, bool Selected = false, bool Disabled = false); + +/// +/// Represents a semantic navigation item. +/// +/// +/// Navigation components consume this model to produce menu and tab structures. +/// +public sealed record Cd311NavItem(string Text, string? Url = null, bool Active = false, bool Disabled = false); + +/// +/// Represents a semantic action item. +/// +/// +/// Action items provide consistent semantics across action bars, dropdowns, and modal actions. +/// +public sealed record Cd311ActionItem(string Text, string? Command = null, bool Primary = false, bool Disabled = false); + +/// +/// Represents a key/value semantic pair. +/// +/// +/// Data components use this model for metadata and summary displays. +/// +public sealed record Cd311KeyValueItem(string Key, string Value); diff --git a/src/Code311.Tabler.Components/Common/TagHelperUtility.cs b/src/Code311.Tabler.Components/Common/TagHelperUtility.cs new file mode 100644 index 0000000..092f4f2 --- /dev/null +++ b/src/Code311.Tabler.Components/Common/TagHelperUtility.cs @@ -0,0 +1,38 @@ +using Code311.Tabler.Core.Mapping; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Common; + +internal static class TagHelperUtility +{ + public static TagHelperContext CreateContext() => + new( + new TagHelperAttributeList(), + new Dictionary(), + Guid.NewGuid().ToString("N")); + + public static TagHelperOutput CreateOutput(string tagName = "div") => + new( + tagName, + new TagHelperAttributeList(), + static (_, _) => Task.FromResult(new DefaultTagHelperContent())); + + public static void AddClass(this TagHelperOutput output, string? @class) + { + if (string.IsNullOrWhiteSpace(@class)) + { + return; + } + + if (output.Attributes.TryGetAttribute("class", out var existing) && existing.Value is string existingValue) + { + output.Attributes.SetAttribute("class", $"{existingValue} {@class}".Trim()); + return; + } + + output.Attributes.SetAttribute("class", @class); + } + + public static string SizeClass(this ITablerSemanticClassMapper mapper, Code311.Ui.Abstractions.Semantics.UiSize size) + => $"size-{mapper.MapSize(size)}"; +} diff --git a/src/Code311.Tabler.Components/Data/DataComponents.cs b/src/Code311.Tabler.Components/Data/DataComponents.cs new file mode 100644 index 0000000..7042423 --- /dev/null +++ b/src/Code311.Tabler.Components/Data/DataComponents.cs @@ -0,0 +1,98 @@ +using Code311.Tabler.Components.Common; +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Data; + +[HtmlTargetElement("cd311-table")] +public sealed class Cd311TableTagHelper : TagHelper +{ + public IReadOnlyCollection? Headers { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "table"; + output.Attributes.SetAttribute("class", "table"); + var headerHtml = string.Join(string.Empty, (Headers ?? []).Select(h => $"{h}")); + output.PreContent.SetHtmlContent($"{headerHtml}"); + } +} + +public sealed class Cd311FilterToolbarViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(IReadOnlyCollection? filters) + { + var html = string.Join(string.Empty, (filters ?? []).Select(f => $"")); + return new HtmlContentViewComponentResult(new HtmlString($"
{html}
")); + } +} + +[HtmlTargetElement("cd311-badge")] +public sealed class Cd311BadgeTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public UiTone Tone { get; set; } = UiTone.Neutral; + public string Text { get; set; } = string.Empty; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "span"; + output.Attributes.SetAttribute("class", $"badge bg-{mapper.MapTone(Tone)}"); + output.Content.SetContent(Text); + } +} + +public sealed class Cd311StatKpiViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string label, string value, UiTone tone = UiTone.Info) + => new HtmlContentViewComponentResult(new HtmlString($"
{label}
{value}
")); +} + +[HtmlTargetElement("cd311-list-group")] +public sealed class Cd311ListGroupTagHelper : TagHelper +{ + public IReadOnlyCollection? Items { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "ul"; + output.Attributes.SetAttribute("class", "list-group"); + foreach (var item in Items ?? []) + { + output.Content.AppendHtml($"
  • {item}
  • "); + } + } +} + +[HtmlTargetElement("cd311-key-value-list")] +public sealed class Cd311KeyValueListTagHelper : TagHelper +{ + public IReadOnlyCollection? Items { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "dl"; + output.Attributes.SetAttribute("class", "row"); + foreach (var kv in Items ?? []) + { + output.Content.AppendHtml($"
    {kv.Key}
    {kv.Value}
    "); + } + } +} + +[HtmlTargetElement("cd311-empty-state")] +public sealed class Cd311EmptyStateTagHelper : TagHelper +{ + public string Title { get; set; } = "No data"; + public string? Description { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", "empty"); + output.Content.SetHtmlContent($"

    {Title}

    {Description}

    "); + } +} diff --git a/src/Code311.Tabler.Components/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Components/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6a2ebe6 --- /dev/null +++ b/src/Code311.Tabler.Components/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Components.DependencyInjection; + +/// +/// Provides dependency-injection registration helpers for Code311 Tabler components. +/// +/// +/// This registration layer wires the component package and relies on existing Ui.Core and Tabler.Core services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Code311 Tabler components package services. + /// + /// The DI service collection. + /// The updated service collection. + /// + /// Current component implementations are primarily TagHelpers and ViewComponents and do not require extra service registrations. + /// + public static IServiceCollection AddCode311TablerComponents(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + return services; + } +} diff --git a/src/Code311.Tabler.Components/Feedback/FeedbackComponents.cs b/src/Code311.Tabler.Components/Feedback/FeedbackComponents.cs new file mode 100644 index 0000000..dae65b8 --- /dev/null +++ b/src/Code311.Tabler.Components/Feedback/FeedbackComponents.cs @@ -0,0 +1,103 @@ +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Feedback; + +[HtmlTargetElement("cd311-alert")] +public sealed class Cd311AlertTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public UiTone Tone { get; set; } = UiTone.Info; + public string? Message { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", $"alert alert-{mapper.MapTone(Tone)}"); + output.Content.SetContent(Message ?? string.Empty); + } +} + +public sealed class Cd311ToastHostViewComponent(IFeedbackChannel feedbackChannel, ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke() + { + var messages = feedbackChannel.Drain(); + var html = string.Join(string.Empty, messages.Select(m => $"
    {m.Message}
    ")); + return new HtmlContentViewComponentResult(new HtmlString($"
    {html}
    ")); + } +} + +public sealed class Cd311ModalViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string id, string title, string? body = null) + => new HtmlContentViewComponentResult(new HtmlString($"
    {title}
    {body}
    ")); +} + +public sealed class Cd311ConfirmDialogViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string id, string question) + => new HtmlContentViewComponentResult(new HtmlString($"
    {question}
    ")); +} + +public sealed class Cd311PageAlertHostViewComponent(IFeedbackChannel feedbackChannel) : ViewComponent +{ + public IViewComponentResult Invoke() + { + var html = string.Join(string.Empty, feedbackChannel.Drain().Select(m => $"
    {m.Message}
    ")); + return new HtmlContentViewComponentResult(new HtmlString($"
    {html}
    ")); + } +} + +[HtmlTargetElement("cd311-busy-overlay")] +public sealed class Cd311BusyOverlayTagHelper(IBusyStateCoordinator coordinator) : TagHelper +{ + public string Scope { get; set; } = "default"; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", $"busy-overlay {(coordinator.IsBusy(Scope) ? "active" : string.Empty)}"); + } +} + +[HtmlTargetElement("cd311-busy-button")] +public sealed class Cd311BusyButtonTagHelper(IBusyStateCoordinator coordinator) : TagHelper +{ + public string Scope { get; set; } = "default"; + public string Label { get; set; } = "Submit"; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "button"; + output.Attributes.SetAttribute("type", "button"); + output.Attributes.SetAttribute("class", "btn btn-primary"); + output.Content.SetContent(coordinator.IsBusy(Scope) ? "Working..." : Label); + } +} + +public sealed class Cd311PagePreloaderViewComponent(IPreloaderOrchestrator preloader) : ViewComponent +{ + public IViewComponentResult Invoke(string key = "default") + => new HtmlContentViewComponentResult(new HtmlString($"
    ")); +} + +[HtmlTargetElement("cd311-progress")] +public sealed class Cd311ProgressTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public int Value { get; set; } + public UiTone Tone { get; set; } = UiTone.Info; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + var clamped = Math.Clamp(Value, 0, 100); + output.TagName = "div"; + output.Attributes.SetAttribute("class", "progress"); + output.Content.SetHtmlContent($"
    "); + } +} diff --git a/src/Code311.Tabler.Components/Forms/FormTagHelpers.cs b/src/Code311.Tabler.Components/Forms/FormTagHelpers.cs new file mode 100644 index 0000000..af31ee1 --- /dev/null +++ b/src/Code311.Tabler.Components/Forms/FormTagHelpers.cs @@ -0,0 +1,154 @@ +using Code311.Tabler.Components.Common; +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Forms; + +/// +/// Renders a semantic text input. +/// +/// +/// This component accepts semantic parameters and maps rendering through Tabler mapping services. +/// +[HtmlTargetElement("cd311-input")] +public sealed class Cd311InputTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Field { get; set; } + public string? Label { get; set; } + public UiState State { get; set; } = UiState.Default; + public UiSize Size { get; set; } = UiSize.Medium; + public UiAppearance Appearance { get; set; } = UiAppearance.Soft; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "input"; + output.TagMode = TagMode.SelfClosing; + output.Attributes.SetAttribute("name", Field ?? string.Empty); + output.Attributes.SetAttribute("aria-label", Label ?? Field ?? "input"); + output.AddClass($"form-control {mapper.SizeClass(Size)} state-{mapper.MapState(State)} app-{mapper.MapAppearance(Appearance)}"); + } +} + +[HtmlTargetElement("cd311-textarea")] +public sealed class Cd311TextAreaTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Field { get; set; } + public int Rows { get; set; } = 3; + public UiState State { get; set; } = UiState.Default; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "textarea"; + output.Attributes.SetAttribute("name", Field ?? string.Empty); + output.Attributes.SetAttribute("rows", Math.Max(1, Rows)); + output.AddClass($"form-control state-{mapper.MapState(State)}"); + } +} + +[HtmlTargetElement("cd311-select")] +public sealed class Cd311SelectTagHelper : TagHelper +{ + public string? Field { get; set; } + public IReadOnlyCollection? Options { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "select"; + output.Attributes.SetAttribute("name", Field ?? string.Empty); + output.AddClass("form-select"); + + foreach (var option in Options ?? []) + { + var selected = option.Selected ? " selected" : string.Empty; + var disabled = option.Disabled ? " disabled" : string.Empty; + output.Content.AppendHtml($""); + } + } +} + +[HtmlTargetElement("cd311-checkbox")] +public sealed class Cd311CheckboxTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Field { get; set; } + public bool Checked { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "input"; + output.TagMode = TagMode.SelfClosing; + output.Attributes.SetAttribute("type", "checkbox"); + output.Attributes.SetAttribute("name", Field ?? string.Empty); + if (Checked) + { + output.Attributes.SetAttribute("checked", "checked"); + } + + output.AddClass($"form-check-input state-{mapper.MapState(Checked ? UiState.Active : UiState.Default)}"); + } +} + +[HtmlTargetElement("cd311-radio-group")] +public sealed class Cd311RadioGroupTagHelper : TagHelper +{ + public string? Field { get; set; } + public IReadOnlyCollection? Options { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.AddClass("cd311-radio-group"); + foreach (var option in Options ?? []) + { + var selected = option.Selected ? " checked" : string.Empty; + var disabled = option.Disabled ? " disabled" : string.Empty; + output.Content.AppendHtml($""); + } + } +} + +[HtmlTargetElement("cd311-switch")] +public sealed class Cd311SwitchTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Field { get; set; } + public bool Enabled { get; set; } + public UiTone Tone { get; set; } = UiTone.Accent; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "input"; + output.TagMode = TagMode.SelfClosing; + output.Attributes.SetAttribute("type", "checkbox"); + output.Attributes.SetAttribute("name", Field ?? string.Empty); + if (Enabled) + { + output.Attributes.SetAttribute("checked", "checked"); + } + + output.AddClass($"form-check-input switch tone-{mapper.MapTone(Tone)}"); + } +} + +[HtmlTargetElement("cd311-input-group")] +public sealed class Cd311InputGroupTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Prefix { get; set; } + public string? Suffix { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.AddClass($"input-group app-{mapper.MapAppearance(UiAppearance.Soft)}"); + if (!string.IsNullOrWhiteSpace(Prefix)) + { + output.Content.AppendHtml($"{Prefix}"); + } + + output.Content.AppendHtml(""); + + if (!string.IsNullOrWhiteSpace(Suffix)) + { + output.Content.AppendHtml($"{Suffix}"); + } + } +} diff --git a/src/Code311.Tabler.Components/Forms/FormViewComponents.cs b/src/Code311.Tabler.Components/Forms/FormViewComponents.cs new file mode 100644 index 0000000..bd7668f --- /dev/null +++ b/src/Code311.Tabler.Components/Forms/FormViewComponents.cs @@ -0,0 +1,39 @@ +using Code311.Tabler.Components.Common; +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; + +namespace Code311.Tabler.Components.Forms; + +/// +/// Renders a semantic form field group. +/// +/// +/// Field grouping is expressed with semantic layout and appearance parameters. +/// +public sealed class Cd311FieldGroupViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string? legend, UiLayout layout = UiLayout.Stack, UiDensity density = UiDensity.Comfortable) + { + var html = $"
    {legend}
    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} + +/// +/// Renders a semantic form actions bar. +/// +/// +/// The actions bar provides consistent action placement semantics for create/edit workflows. +/// +public sealed class Cd311FormActionsBarViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(IReadOnlyCollection? actions, UiPinned pinned = UiPinned.None) + { + var items = string.Join(string.Empty, (actions ?? []).Select(a => $"")); + var html = $"
    {items}
    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} diff --git a/src/Code311.Tabler.Components/Layout/LayoutComponents.cs b/src/Code311.Tabler.Components/Layout/LayoutComponents.cs new file mode 100644 index 0000000..b9fc0a5 --- /dev/null +++ b/src/Code311.Tabler.Components/Layout/LayoutComponents.cs @@ -0,0 +1,94 @@ +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Layout; + +[HtmlTargetElement("cd311-container")] +public sealed class Cd311ContainerTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public UiLayout Layout { get; set; } = UiLayout.Stack; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", $"container {mapper.MapLayout(Layout)}"); + } +} + +[HtmlTargetElement("cd311-section")] +public sealed class Cd311SectionTagHelper : TagHelper +{ + public string? Title { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "section"; + if (!string.IsNullOrWhiteSpace(Title)) + { + output.PreContent.SetHtmlContent($"

    {Title}

    "); + } + } +} + +public sealed class Cd311PageHeaderViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string title, string? subtitle = null) + { + var html = $"

    {title}

    {subtitle}

    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} + +[HtmlTargetElement("cd311-card")] +public sealed class Cd311CardTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string? Title { get; set; } + public UiTone Tone { get; set; } = UiTone.Neutral; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "article"; + output.Attributes.SetAttribute("class", $"card border-{mapper.MapTone(Tone)}"); + if (!string.IsNullOrWhiteSpace(Title)) + { + output.PreContent.SetHtmlContent($"
    {Title}
    "); + } + } +} + +[HtmlTargetElement("cd311-grid")] +public sealed class Cd311GridTagHelper : TagHelper +{ + public int Columns { get; set; } = 2; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", $"row row-cols-{Math.Max(1, Columns)}"); + } +} + +[HtmlTargetElement("cd311-stack")] +public sealed class Cd311StackTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public UiDensity Density { get; set; } = UiDensity.Comfortable; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "div"; + output.Attributes.SetAttribute("class", $"d-flex flex-column {mapper.MapDensity(Density)}"); + } +} + +public sealed class Cd311AccordionViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(IReadOnlyCollection? items) + { + var html = string.Join(string.Empty, (items ?? []).Select((x, i) => $"

    {x}

    ")); + return new HtmlContentViewComponentResult(new HtmlString($"
    {html}
    ")); + } +} diff --git a/src/Code311.Tabler.Components/Media/MediaComponents.cs b/src/Code311.Tabler.Components/Media/MediaComponents.cs new file mode 100644 index 0000000..672f903 --- /dev/null +++ b/src/Code311.Tabler.Components/Media/MediaComponents.cs @@ -0,0 +1,70 @@ +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Media; + +[HtmlTargetElement("cd311-avatar")] +public sealed class Cd311AvatarTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public string Name { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public UiTone Tone { get; set; } = UiTone.Neutral; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "span"; + output.Attributes.SetAttribute("class", $"avatar bg-{mapper.MapTone(Tone)}"); + if (!string.IsNullOrWhiteSpace(ImageUrl)) + { + output.Attributes.SetAttribute("style", $"background-image:url('{ImageUrl}')"); + } + else + { + output.Content.SetContent(new string(Name.Where(char.IsLetter).Take(2).ToArray()).ToUpperInvariant()); + } + } +} + +[HtmlTargetElement("cd311-icon")] +public sealed class Cd311IconTagHelper : TagHelper +{ + public string Name { get; set; } = "circle"; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "i"; + output.Attributes.SetAttribute("class", $"ti ti-{Name}"); + } +} + +[HtmlTargetElement("cd311-image")] +public sealed class Cd311ImageTagHelper : TagHelper +{ + public string Src { get; set; } = string.Empty; + public string Alt { get; set; } = string.Empty; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "img"; + output.TagMode = TagMode.SelfClosing; + output.Attributes.SetAttribute("src", Src); + output.Attributes.SetAttribute("alt", Alt); + output.Attributes.SetAttribute("class", "img-fluid"); + } +} + +public sealed class Cd311FilePreviewViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string fileName, string mediaType) + => new HtmlContentViewComponentResult(new HtmlString($"
    {fileName}{mediaType}
    ")); +} + +public sealed class Cd311ProfileBlockViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string title, string subtitle) + => new HtmlContentViewComponentResult(new HtmlString($"

    {title}

    {subtitle}

    ")); +} diff --git a/src/Code311.Tabler.Components/Navigation/NavigationComponents.cs b/src/Code311.Tabler.Components/Navigation/NavigationComponents.cs new file mode 100644 index 0000000..157cdf0 --- /dev/null +++ b/src/Code311.Tabler.Components/Navigation/NavigationComponents.cs @@ -0,0 +1,84 @@ +using Code311.Tabler.Components.Common; +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Code311.Tabler.Components.Navigation; + +public sealed class Cd311TopNavViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string? brand, IReadOnlyCollection? items, UiPinned pinned = UiPinned.None) + { + var links = string.Join(string.Empty, (items ?? []).Select(i => $"{i.Text}")); + return new HtmlContentViewComponentResult(new HtmlString($"")); + } +} + +public sealed class Cd311SidebarNavViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(IReadOnlyCollection? sections, UiState state = UiState.Default) + { + var links = string.Join(string.Empty, (sections ?? []).Select(i => $"
  • {i.Text}
  • ")); + return new HtmlContentViewComponentResult(new HtmlString($"")); + } +} + +[HtmlTargetElement("cd311-breadcrumb")] +public sealed class Cd311BreadcrumbTagHelper : TagHelper +{ + public IReadOnlyCollection? Items { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "nav"; + output.AddClass("breadcrumb"); + var html = string.Join(string.Empty, (Items ?? []).Select(i => $"{i.Text}")); + output.Content.SetHtmlContent(html); + } +} + +[HtmlTargetElement("cd311-pagination")] +public sealed class Cd311PaginationTagHelper : TagHelper +{ + public int Page { get; set; } = 1; + public int Total { get; set; } = 1; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "ul"; + output.AddClass("pagination"); + for (var i = 1; i <= Math.Max(1, Total); i++) + { + output.Content.AppendHtml($"
  • {i}
  • "); + } + } +} + +[HtmlTargetElement("cd311-tabs")] +public sealed class Cd311TabsTagHelper(ITablerSemanticClassMapper mapper) : TagHelper +{ + public IReadOnlyCollection? Items { get; set; } + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + output.TagName = "ul"; + output.AddClass($"nav nav-tabs {mapper.MapLayout(UiLayout.Inline)}"); + foreach (var item in Items ?? []) + { + output.Content.AppendHtml($"
  • {item.Text}
  • "); + } + } +} + +public sealed class Cd311DropdownMenuViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string title, IReadOnlyCollection? items) + { + var htmlItems = string.Join(string.Empty, (items ?? []).Select(i => $"
  • ")); + var html = $"
      {htmlItems}
    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} diff --git a/src/Code311.Tabler.Core/.gitkeep b/src/Code311.Tabler.Core/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Core/Assets/TablerAssets.cs b/src/Code311.Tabler.Core/Assets/TablerAssets.cs new file mode 100644 index 0000000..c168736 --- /dev/null +++ b/src/Code311.Tabler.Core/Assets/TablerAssets.cs @@ -0,0 +1,60 @@ +namespace Code311.Tabler.Core.Assets; + +/// +/// Identifies Tabler asset types. +/// +/// +/// Asset type values are consumed by host adapters for manifest generation. +/// +public enum TablerAssetType +{ + Script, + Style +} + +/// +/// Represents a Tabler asset manifest descriptor. +/// +/// The asset path. +/// The asset type. +/// The deterministic load order. +/// +/// The descriptor remains neutral to host transport mechanisms. +/// +public sealed record TablerAssetDescriptor(string Path, TablerAssetType Type, int Order = 0); + +/// +/// Provides Tabler asset manifest entries. +/// +/// +/// Providers can be extended by additional Tabler packages in later phases. +/// +public interface ITablerAssetManifestProvider +{ + /// + /// Returns ordered asset descriptors for Tabler core. + /// + /// A read-only list of ordered descriptors. + /// + /// Consumers should preserve list order when constructing runtime manifests. + /// + IReadOnlyList GetAssets(); +} + +/// +/// Default Tabler core asset manifest provider. +/// +/// +/// This provider includes only foundational Tabler assets and can be augmented by higher packages. +/// +public sealed class DefaultTablerAssetManifestProvider : ITablerAssetManifestProvider +{ + private static readonly IReadOnlyList Assets = + [ + new("_content/Code311.Tabler.Core/css/tabler.min.css", TablerAssetType.Style, 10), + new("_content/Code311.Tabler.Core/js/tabler.min.js", TablerAssetType.Script, 20) + ]; + + /// + public IReadOnlyList GetAssets() => Assets; +} diff --git a/src/Code311.Tabler.Core/Code311.Tabler.Core.csproj b/src/Code311.Tabler.Core/Code311.Tabler.Core.csproj new file mode 100644 index 0000000..41c984b --- /dev/null +++ b/src/Code311.Tabler.Core/Code311.Tabler.Core.csproj @@ -0,0 +1,16 @@ + + + net10.0 + Code311.Tabler.Core + Code311.Tabler.Core + Tabler mapping and asset foundations for Code311. + Code311.Tabler.Core + + + + + + + + + diff --git a/src/Code311.Tabler.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Core/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..997e5c0 --- /dev/null +++ b/src/Code311.Tabler.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using Code311.Ui.Abstractions.Internal.Contracts; +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Core.Theming; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Core.DependencyInjection; + +/// +/// Provides dependency-injection registrations for Tabler core mapping and assets. +/// +/// +/// This extension method registers only Tabler-core foundations and no component/UI runtime implementation. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Tabler core services. + /// + /// The DI service collection. + /// The updated service collection. + /// + /// The semantic mapper is registered as all approved Tabler mapper abstraction contracts. + /// + public static IServiceCollection AddCode311TablerCore(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Code311.Tabler.Core/Mapping/TablerSemanticClassMapper.cs b/src/Code311.Tabler.Core/Mapping/TablerSemanticClassMapper.cs new file mode 100644 index 0000000..3b6e668 --- /dev/null +++ b/src/Code311.Tabler.Core/Mapping/TablerSemanticClassMapper.cs @@ -0,0 +1,198 @@ +using Code311.Ui.Abstractions.Internal.Contracts; +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Tabler.Core.Mapping; + +/// +/// Exposes semantic-to-Tabler class mapping operations. +/// +/// +/// This mapper centralizes Tabler-specific class translations so upstream packages stay semantic and neutral. +/// +public interface ITablerSemanticClassMapper +{ + /// + /// Maps semantic tone to Tabler class suffix. + /// + /// The semantic tone. + /// The mapped Tabler class token. + /// + /// Returned values are implementation details for Tabler packages. + /// + string MapTone(UiTone tone); + + /// + /// Maps semantic appearance to Tabler class token. + /// + /// The semantic appearance. + /// The mapped class token. + /// + /// The mapping avoids exposing Tabler tokens in consumer APIs. + /// + string MapAppearance(UiAppearance appearance); + + /// + /// Maps semantic density to Tabler spacing class token. + /// + /// The semantic density. + /// The mapped class token. + /// + /// Density mappings are used by layout and form groups. + /// + string MapDensity(UiDensity density); + + /// + /// Maps semantic size to Tabler size class token. + /// + /// The semantic size. + /// The mapped class token. + /// + /// Size mappings can be reused by controls and media. + /// + string MapSize(UiSize size); + + /// + /// Maps semantic state to Tabler class token. + /// + /// The semantic state. + /// The mapped class token. + /// + /// State mappings support active, disabled, read-only, and busy scenarios. + /// + string MapState(UiState state); + + /// + /// Maps semantic placement to Tabler class token. + /// + /// The semantic placement. + /// The mapped class token. + /// + /// Placement tokens are used by popovers, tooltips, and toast positions. + /// + string MapPlacement(UiPlacement placement); + + /// + /// Maps semantic layout to Tabler class token. + /// + /// The semantic layout intent. + /// The mapped class token. + /// + /// Layout mappings are intentionally coarse and can be refined in later phases. + /// + string MapLayout(UiLayout layout); + + /// + /// Maps semantic pinning to Tabler class token. + /// + /// The semantic pinning intent. + /// The mapped class token. + /// + /// Pinning mappings are consumed by header/sidebar shell components. + /// + string MapPinned(UiPinned pinned); +} + +/// +/// Default semantic-to-Tabler mapper implementation. +/// +/// +/// This type implements all approved Tabler mapper abstraction contracts. +/// +public sealed class TablerSemanticClassMapper : + ITablerSemanticClassMapper, + ITablerFormClassMapper, + ITablerNavigationClassMapper, + ITablerLayoutClassMapper, + ITablerFeedbackClassMapper, + ITablerDataClassMapper, + ITablerMediaClassMapper +{ + /// + public string MapTone(UiTone tone) => tone switch + { + UiTone.Neutral => "secondary", + UiTone.Accent => "primary", + UiTone.Success => "success", + UiTone.Warning => "warning", + UiTone.Danger => "danger", + UiTone.Info => "info", + _ => "secondary" + }; + + /// + public string MapAppearance(UiAppearance appearance) => appearance switch + { + UiAppearance.Solid => "", + UiAppearance.Soft => "-lt", + UiAppearance.Outline => "-outline", + UiAppearance.Ghost => "-ghost", + UiAppearance.Link => "-link", + _ => string.Empty + }; + + /// + public string MapDensity(UiDensity density) => density switch + { + UiDensity.Compact => "density-compact", + UiDensity.Comfortable => "density-comfortable", + UiDensity.Spacious => "density-spacious", + _ => "density-comfortable" + }; + + /// + public string MapSize(UiSize size) => size switch + { + UiSize.Small => "sm", + UiSize.Medium => "md", + UiSize.Large => "lg", + _ => "md" + }; + + /// + public string MapState(UiState state) => state switch + { + UiState.Default => "", + UiState.Active => "active", + UiState.Disabled => "disabled", + UiState.ReadOnly => "readonly", + UiState.Busy => "busy", + UiState.Hidden => "d-none", + _ => string.Empty + }; + + /// + public string MapPlacement(UiPlacement placement) => placement switch + { + UiPlacement.Top => "top", + UiPlacement.TopStart => "top-start", + UiPlacement.TopEnd => "top-end", + UiPlacement.Right => "right", + UiPlacement.Bottom => "bottom", + UiPlacement.BottomStart => "bottom-start", + UiPlacement.BottomEnd => "bottom-end", + UiPlacement.Left => "left", + UiPlacement.Center => "center", + _ => "top" + }; + + /// + public string MapLayout(UiLayout layout) => layout switch + { + UiLayout.Inline => "d-inline-flex", + UiLayout.Stack => "d-flex flex-column", + UiLayout.Grid => "row", + UiLayout.Split => "d-flex justify-content-between", + UiLayout.Fill => "w-100", + _ => "d-flex" + }; + + /// + public string MapPinned(UiPinned pinned) => pinned switch + { + UiPinned.None => string.Empty, + UiPinned.Start => "sticky-top", + UiPinned.End => "sticky-bottom", + UiPinned.Both => "position-sticky", + _ => string.Empty + }; +} diff --git a/src/Code311.Tabler.Core/Theming/TablerThemeMapper.cs b/src/Code311.Tabler.Core/Theming/TablerThemeMapper.cs new file mode 100644 index 0000000..686177f --- /dev/null +++ b/src/Code311.Tabler.Core/Theming/TablerThemeMapper.cs @@ -0,0 +1,111 @@ +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Code311.Tabler.Core.Mapping; + +namespace Code311.Tabler.Core.Theming; + +/// +/// Represents a resolved Tabler theme mapping result. +/// +/// The semantic theme profile name. +/// The mapped body class string. +/// The mapped navbar class string. +/// The mapped sidebar class string. +/// +/// This model is used by adapters and components to compose deterministic root layout classes. +/// +public sealed record TablerThemeMap(string ThemeName, string BodyClass, string NavbarClass, string SidebarClass); + +/// +/// Maps semantic theme profiles to Tabler layout classes. +/// +/// +/// Theme mapping centralizes Tabler-specific class generation and keeps upstream services semantic. +/// +public interface ITablerThemeMapper +{ + /// + /// Maps a semantic profile to a Tabler theme map. + /// + /// The semantic theme profile. + /// The mapped Tabler theme data. + /// + /// Implementations should be deterministic for predictable rendering and testing. + /// + TablerThemeMap Map(ThemeProfile profile); +} + +/// +/// Default semantic-to-Tabler theme mapper. +/// +/// +/// The mapper composes layout and density classes using semantic profile inputs. +/// +public sealed class TablerThemeMapper : ITablerThemeMapper +{ + private readonly ITablerSemanticClassMapper _mapper; + + /// + /// Initializes a new instance of the class. + /// + /// The semantic class mapper dependency. + /// + /// Mapper dependency is required to keep class token generation consistent. + /// + public TablerThemeMapper(ITablerSemanticClassMapper mapper) + { + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + + /// + public TablerThemeMap Map(ThemeProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + var bodyClasses = new List + { + _mapper.MapDensity(profile.Density) + }; + + if (profile.DarkMode) + { + bodyClasses.Add("theme-dark"); + } + + var navbarClasses = new List + { + "navbar", + $"navbar-{MapNavbar(profile.NavbarStyle)}", + $"bg-{_mapper.MapTone(profile.Tone)}" + }; + + var sidebarClasses = new List + { + "navbar-vertical", + $"sidebar-{MapSidebar(profile.SidebarMode)}" + }; + + return new TablerThemeMap( + profile.Name, + string.Join(' ', bodyClasses.Where(static c => !string.IsNullOrWhiteSpace(c))), + string.Join(' ', navbarClasses), + string.Join(' ', sidebarClasses)); + } + + private static string MapSidebar(SidebarMode mode) => mode switch + { + SidebarMode.Expanded => "expanded", + SidebarMode.Collapsed => "collapsed", + SidebarMode.Overlay => "overlay", + _ => "expanded" + }; + + private static string MapNavbar(NavbarStyle style) => style switch + { + NavbarStyle.Default => "default", + NavbarStyle.Contrast => "dark", + NavbarStyle.Minimal => "transparent", + NavbarStyle.Elevated => "elevated", + _ => "default" + }; +} diff --git a/src/Code311.Tabler.Core/Widgets/WidgetSlotContracts.cs b/src/Code311.Tabler.Core/Widgets/WidgetSlotContracts.cs new file mode 100644 index 0000000..4f07239 --- /dev/null +++ b/src/Code311.Tabler.Core/Widgets/WidgetSlotContracts.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using Code311.Tabler.Core.Assets; + +namespace Code311.Tabler.Core.Widgets; + +public sealed record TablerWidgetOptionsEnvelope(IReadOnlyDictionary Values); + +public sealed record TablerWidgetSlotDefinition(string WidgetKey, string SlotIntent, TablerWidgetOptionsEnvelope Options); + +public sealed record TablerWidgetInitializationRequest(string WidgetKey, string ElementId, string OptionsJson); + +public interface ITablerWidgetSlotParticipant +{ + TablerWidgetSlotDefinition Slot { get; } + IReadOnlyList GetAssetContributions(); + TablerWidgetInitializationRequest CreateInitialization(string elementId); +} + +public static class TablerWidgetInitializationSerializer +{ + public static string Serialize(TablerWidgetOptionsEnvelope options) + { + ArgumentNullException.ThrowIfNull(options); + return JsonSerializer.Serialize(options.Values); + } +} diff --git a/src/Code311.Tabler.Dashboard/.gitkeep b/src/Code311.Tabler.Dashboard/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Dashboard/Code311.Tabler.Dashboard.csproj b/src/Code311.Tabler.Dashboard/Code311.Tabler.Dashboard.csproj new file mode 100644 index 0000000..b92f4bd --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Code311.Tabler.Dashboard.csproj @@ -0,0 +1,18 @@ + + + net10.0 + Code311.Tabler.Dashboard + Code311.Tabler.Dashboard + Composition-focused dashboard package for Code311 Tabler. + Code311.Tabler.Dashboard + true + + + + + + + + + + diff --git a/src/Code311.Tabler.Dashboard/Composition/DashboardCompositionContracts.cs b/src/Code311.Tabler.Dashboard/Composition/DashboardCompositionContracts.cs new file mode 100644 index 0000000..808a261 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Composition/DashboardCompositionContracts.cs @@ -0,0 +1,47 @@ +using Code311.Tabler.Dashboard.Models; + +namespace Code311.Tabler.Dashboard.Composition; + +/// +/// Provides dashboard page composition helpers. +/// +/// +/// The composer transforms zone and panel models into composition-ready HTML fragments. +/// +public interface IDashboardPageComposer +{ + /// + /// Composes a dashboard page model into HTML. + /// + /// The page model. + /// A composed HTML payload. + string Compose(DashboardPageModel model); +} + +/// +/// Provides optional dashboard personalization hooks. +/// +/// +/// Hooks remain abstraction-only and avoid persistence implementation details. +/// +public interface IDashboardPersonalizationHook +{ + /// + /// Applies personalization to a page model. + /// + /// The model to personalize. + /// The updated model. + DashboardPageModel Apply(DashboardPageModel model); +} + +internal sealed class DefaultDashboardPageComposer : IDashboardPageComposer +{ + public string Compose(DashboardPageModel model) + { + ArgumentNullException.ThrowIfNull(model); + var zones = string.Join(string.Empty, model.Zones.Select(z => + $"

    {z.Title}

    {string.Join(string.Empty, z.Panels.Select(p => p.BodyHtml))}
    ")); + + return $"

    {model.Title}

    {zones}
    "; + } +} diff --git a/src/Code311.Tabler.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2226eab --- /dev/null +++ b/src/Code311.Tabler.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using Code311.Tabler.Components.DependencyInjection; +using Code311.Tabler.Core.DependencyInjection; +using Code311.Tabler.Dashboard.Composition; +using Code311.Ui.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Code311.Tabler.Dashboard.DependencyInjection; + +/// +/// Provides dependency-injection registrations for Code311 Tabler dashboard composition services. +/// +/// +/// Registration composes existing Ui/Core/Components packages without introducing widget or persistence dependencies. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers dashboard composition services. + /// + /// The service collection. + /// The updated service collection. + public static IServiceCollection AddCode311TablerDashboard(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddCode311UiCore(); + services.AddCode311TablerCore(); + services.AddCode311TablerComponents(); + + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Code311.Tabler.Dashboard/Kpi/KpiViewComponents.cs b/src/Code311.Tabler.Dashboard/Kpi/KpiViewComponents.cs new file mode 100644 index 0000000..d3ef221 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Kpi/KpiViewComponents.cs @@ -0,0 +1,21 @@ +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Dashboard.Models; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; + +namespace Code311.Tabler.Dashboard.Kpi; + +/// +/// Renders KPI summary surfaces for dashboard pages. +/// +public sealed class Cd311KpiSummaryViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string title, IReadOnlyCollection? items) + { + var htmlItems = string.Join(string.Empty, (items ?? []).Select(i => + $"
    {i.Label}
    {i.Value}{i.Trend}
    ")); + + return new HtmlContentViewComponentResult(new HtmlString($"

    {title}

    {htmlItems}
    ")); + } +} diff --git a/src/Code311.Tabler.Dashboard/Layout/DashboardShellViewComponent.cs b/src/Code311.Tabler.Dashboard/Layout/DashboardShellViewComponent.cs new file mode 100644 index 0000000..cf36042 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Layout/DashboardShellViewComponent.cs @@ -0,0 +1,41 @@ +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Dashboard.Models; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; + +namespace Code311.Tabler.Dashboard.Layout; + +/// +/// Renders the top-level dashboard shell. +/// +/// +/// Shell rendering focuses on composition regions and reuses semantic mappings from Tabler.Core. +/// +public sealed class Cd311DashboardShellViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + /// + /// Renders a dashboard shell from page model. + /// + /// The dashboard page model. + /// The semantic shell layout. + public IViewComponentResult Invoke(DashboardPageModel page, UiLayout layout = UiLayout.Grid) + { + var zoneHtml = string.Join(string.Empty, page.Zones.Select(z => $"

    {z.Title}

    ")); + var html = $"

    {page.Title}

    {zoneHtml}
    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} + +/// +/// Renders a dashboard zone layout container. +/// +public sealed class Cd311DashboardZoneViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(DashboardZoneModel zone) + { + var html = $"

    {zone.Title}

    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} diff --git a/src/Code311.Tabler.Dashboard/Models/DashboardModels.cs b/src/Code311.Tabler.Dashboard/Models/DashboardModels.cs new file mode 100644 index 0000000..1381b88 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Models/DashboardModels.cs @@ -0,0 +1,60 @@ +using Code311.Tabler.Components.Common; +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Tabler.Dashboard.Models; + +/// +/// Represents a dashboard page model. +/// +/// +/// Dashboard pages are composition surfaces that organize semantic zones and panels. +/// +public sealed record DashboardPageModel(string Title, IReadOnlyCollection Zones); + +/// +/// Represents a dashboard zone/region. +/// +/// The zone key. +/// The zone title. +/// The semantic layout for the zone. +/// Panels in this zone. +/// +/// Zones are intentionally generic so future personalization can reorder or hide zones. +/// +public sealed record DashboardZoneModel(string Key, string Title, UiLayout Layout, IReadOnlyCollection Panels); + +/// +/// Represents a dashboard panel composition model. +/// +/// The panel key. +/// The panel title. +/// Panel body HTML. +/// Panel semantic tone. +/// +/// Panels are composition-level units and do not own base component semantics. +/// +public sealed record DashboardPanelModel(string PanelKey, string Title, string BodyHtml, UiTone Tone = UiTone.Neutral); + +/// +/// Represents a KPI/stat summary item. +/// +/// The KPI label. +/// The KPI value. +/// The semantic tone. +/// Optional trend label. +public sealed record DashboardKpiItem(string Label, string Value, UiTone Tone = UiTone.Info, string? Trend = null); + +/// +/// Represents an activity feed item. +/// +/// The activity text. +/// The occurrence timestamp. +/// The semantic tone. +public sealed record DashboardActivityItem(string Text, DateTimeOffset OccurredAt, UiTone Tone = UiTone.Neutral); + +/// +/// Represents quick action entry metadata. +/// +/// The action descriptor. +/// The semantic tone. +public sealed record DashboardQuickAction(Cd311ActionItem Action, UiTone Tone = UiTone.Accent); diff --git a/src/Code311.Tabler.Dashboard/Panels/DashboardPanelViewComponents.cs b/src/Code311.Tabler.Dashboard/Panels/DashboardPanelViewComponents.cs new file mode 100644 index 0000000..b7c0ec3 --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Panels/DashboardPanelViewComponents.cs @@ -0,0 +1,44 @@ +using Code311.Tabler.Components.Common; +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Dashboard.Models; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewComponents; + +namespace Code311.Tabler.Dashboard.Panels; + +/// +/// Renders a semantic metric card panel. +/// +public sealed class Cd311MetricCardViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(DashboardPanelModel panel) + { + var html = $"
    {panel.Title}
    {panel.BodyHtml}
    "; + return new HtmlContentViewComponentResult(new HtmlString(html)); + } +} + +/// +/// Renders an activity feed panel. +/// +public sealed class Cd311ActivityPanelViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string title, IReadOnlyCollection? items) + { + var htmlItems = string.Join(string.Empty, (items ?? []).Select(i => $"
  • {i.Text} {i.OccurredAt:O}
  • ")); + return new HtmlContentViewComponentResult(new HtmlString($"

    {title}

      {htmlItems}
    ")); + } +} + +/// +/// Renders a quick actions panel. +/// +public sealed class Cd311QuickActionsPanelViewComponent(ITablerSemanticClassMapper mapper) : ViewComponent +{ + public IViewComponentResult Invoke(string title, IReadOnlyCollection? actions) + { + var htmlItems = string.Join(string.Empty, (actions ?? []).Select(a => $"")); + return new HtmlContentViewComponentResult(new HtmlString($"

    {title}

    {htmlItems}
    ")); + } +} diff --git a/src/Code311.Tabler.Dashboard/Properties/AssemblyInfo.cs b/src/Code311.Tabler.Dashboard/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..7645eed --- /dev/null +++ b/src/Code311.Tabler.Dashboard/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Code311.Tests.Tabler.Dashboard")] diff --git a/src/Code311.Tabler.Mvc/.gitkeep b/src/Code311.Tabler.Mvc/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Mvc/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Mvc/Assets/AssetModels.cs b/src/Code311.Tabler.Mvc/Assets/AssetModels.cs new file mode 100644 index 0000000..8be0807 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Assets/AssetModels.cs @@ -0,0 +1,57 @@ +namespace Code311.Tabler.Mvc.Assets; + +/// +/// Represents a scoped asset descriptor registered during a request. +/// +/// The asset path. +/// Indicates whether the asset is a script entry. +/// +/// Asset descriptors remain host-adapter concerns and are resolved at view rendering time. +/// +public sealed record Code311ScopedAsset(string Path, bool IsScript); + +/// +/// Stores request-scoped asset registrations. +/// +/// +/// The store provides deterministic per-request asset handoff for MVC views. +/// +public interface ICode311AssetRequestStore +{ + /// + /// Registers a style asset path. + /// + /// The style path. + void AddStyle(string path); + + /// + /// Registers a script asset path. + /// + /// The script path. + void AddScript(string path); + + /// + /// Returns all registered assets. + /// + /// Read-only ordered assets. + IReadOnlyList GetAll(); +} + +internal sealed class Code311AssetRequestStore : ICode311AssetRequestStore +{ + private readonly List _assets = []; + + public void AddStyle(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _assets.Add(new Code311ScopedAsset(path, false)); + } + + public void AddScript(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _assets.Add(new Code311ScopedAsset(path, true)); + } + + public IReadOnlyList GetAll() => _assets; +} diff --git a/src/Code311.Tabler.Mvc/Assets/WidgetAssetRequestStoreExtensions.cs b/src/Code311.Tabler.Mvc/Assets/WidgetAssetRequestStoreExtensions.cs new file mode 100644 index 0000000..365ccb8 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Assets/WidgetAssetRequestStoreExtensions.cs @@ -0,0 +1,25 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Widgets; + +namespace Code311.Tabler.Mvc.Assets; + +public static class WidgetAssetRequestStoreExtensions +{ + public static void AddWidgetAssets(this ICode311AssetRequestStore store, ITablerWidgetSlotParticipant widget) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(widget); + + foreach (var asset in widget.GetAssetContributions().OrderBy(a => a.Order)) + { + if (asset.Type == TablerAssetType.Script) + { + store.AddScript(asset.Path); + } + else + { + store.AddStyle(asset.Path); + } + } + } +} diff --git a/src/Code311.Tabler.Mvc/Code311.Tabler.Mvc.csproj b/src/Code311.Tabler.Mvc/Code311.Tabler.Mvc.csproj new file mode 100644 index 0000000..28d4992 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Code311.Tabler.Mvc.csproj @@ -0,0 +1,17 @@ + + + net10.0 + Code311.Tabler.Mvc + Code311.Tabler.Mvc + MVC integration adapter for Code311 Tabler packages. + Code311.Tabler.Mvc + + + + + + + + + + diff --git a/src/Code311.Tabler.Mvc/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Mvc/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..43d12e2 --- /dev/null +++ b/src/Code311.Tabler.Mvc/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +using Code311.Tabler.Components.DependencyInjection; +using Code311.Tabler.Core.DependencyInjection; +using Code311.Tabler.Mvc.Assets; +using Code311.Tabler.Mvc.Feedback; +using Code311.Tabler.Mvc.Filters; +using Code311.Tabler.Mvc.Theming; +using Code311.Ui.Core.DependencyInjection; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Code311.Tabler.Mvc.DependencyInjection; + +/// +/// Provides MVC adapter registration helpers for Code311 Tabler integration. +/// +/// +/// The adapter is intentionally thin and wires existing core services into MVC request lifecycle hooks. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Code311 Tabler MVC integration services. + /// + /// The service collection. + /// The updated service collection. + /// + /// This method wires Ui.Core, Tabler.Core, Tabler.Components and MVC lifecycle filters. + /// + public static IServiceCollection AddCode311TablerMvc(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddCode311UiCore(); + services.AddCode311TablerCore(); + services.AddCode311TablerComponents(); + + // Override default singleton orchestration with request-scoped instances for deterministic host behavior. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddHttpContextAccessor(); + services.AddTransient, ConfigureCode311MvcOptions>(); + + return services; + } +} + +internal sealed class ConfigureCode311MvcOptions : IConfigureOptions +{ + public void Configure(MvcOptions options) + { + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + } +} diff --git a/src/Code311.Tabler.Mvc/Feedback/RequestFeedback.cs b/src/Code311.Tabler.Mvc/Feedback/RequestFeedback.cs new file mode 100644 index 0000000..d7fb682 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Feedback/RequestFeedback.cs @@ -0,0 +1,37 @@ +using Code311.Ui.Core.Feedback; + +namespace Code311.Tabler.Mvc.Feedback; + +/// +/// Stores request-scoped feedback messages for MVC lifecycle handoff. +/// +/// +/// This store complements by preserving per-request determinism. +/// +public interface ICode311RequestFeedbackStore +{ + /// + /// Adds a feedback message to the request scope. + /// + /// The message. + void Add(FeedbackMessage message); + + /// + /// Gets all request-scoped messages. + /// + /// The current request message list. + IReadOnlyList GetAll(); +} + +internal sealed class Code311RequestFeedbackStore : ICode311RequestFeedbackStore +{ + private readonly List _messages = []; + + public void Add(FeedbackMessage message) + { + ArgumentNullException.ThrowIfNull(message); + _messages.Add(message); + } + + public IReadOnlyList GetAll() => _messages; +} diff --git a/src/Code311.Tabler.Mvc/Filters/Code311MvcFilters.cs b/src/Code311.Tabler.Mvc/Filters/Code311MvcFilters.cs new file mode 100644 index 0000000..1d6775f --- /dev/null +++ b/src/Code311.Tabler.Mvc/Filters/Code311MvcFilters.cs @@ -0,0 +1,88 @@ +using Code311.Tabler.Mvc.Feedback; +using Code311.Tabler.Mvc.Theming; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Code311.Tabler.Mvc.Filters; + +/// +/// Captures MVC model-state errors into Code311 feedback channels. +/// +/// +/// The filter keeps request-scoped feedback deterministic by writing to both scoped store and feedback channel. +/// +public sealed class Code311FeedbackActionFilter( + IFeedbackChannel feedbackChannel, + ICode311RequestFeedbackStore requestFeedbackStore) : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + if (context.ModelState.IsValid) + { + return; + } + + foreach (var kvp in context.ModelState) + { + foreach (var err in kvp.Value?.Errors ?? []) + { + var message = new FeedbackMessage(UiTone.Danger, err.ErrorMessage, "MODEL_STATE", DateTimeOffset.UtcNow); + feedbackChannel.Publish(message); + requestFeedbackStore.Add(message); + } + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} + +/// +/// Coordinates request-scope busy/preloader transitions for MVC requests. +/// +/// +/// The filter ensures request-level start/complete orchestration for loader-related services. +/// +public sealed class Code311BusyTransitionFilter( + IBusyStateCoordinator busyStateCoordinator, + IPreloaderOrchestrator preloaderOrchestrator) : IActionFilter +{ + private const string RequestScope = "mvc-request"; + private IDisposable? _busyScope; + + public void OnActionExecuting(ActionExecutingContext context) + { + _busyScope = busyStateCoordinator.EnterScope(RequestScope); + preloaderOrchestrator.Start(RequestScope); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + _busyScope?.Dispose(); + preloaderOrchestrator.Complete(RequestScope); + } +} + +/// +/// Resolves and attaches a semantic theme profile to the current request. +/// +/// +/// Theme resolution uses and stores the result in request-scoped theme context. +/// +public sealed class Code311ThemeContextFilter( + IThemeProfileResolver themeProfileResolver, + ICode311ThemeRequestContext themeRequestContext) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var requestedTheme = context.HttpContext.Request.Query["theme"].FirstOrDefault() ?? "default"; + themeRequestContext.Current = await themeProfileResolver.ResolveAsync(requestedTheme, context.HttpContext.RequestAborted) + .ConfigureAwait(false); + + await next().ConfigureAwait(false); + } +} diff --git a/src/Code311.Tabler.Mvc/Helpers/Code311ControllerBase.cs b/src/Code311.Tabler.Mvc/Helpers/Code311ControllerBase.cs new file mode 100644 index 0000000..7963841 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Helpers/Code311ControllerBase.cs @@ -0,0 +1,27 @@ +using Code311.Tabler.Mvc.Feedback; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Microsoft.AspNetCore.Mvc; + +namespace Code311.Tabler.Mvc.Helpers; + +/// +/// Provides optional, lightweight base controller helpers for Code311-enabled MVC apps. +/// +/// +/// Inheriting from this type is optional and does not affect integration behavior. +/// +public abstract class Code311ControllerBase(IFeedbackChannel feedbackChannel, ICode311RequestFeedbackStore requestFeedbackStore) : Controller +{ + /// + /// Publishes a semantic feedback message for the current request. + /// + /// The semantic tone. + /// The message. + protected void PublishFeedback(UiTone tone, string message) + { + var model = new FeedbackMessage(tone, message, null, DateTimeOffset.UtcNow); + feedbackChannel.Publish(model); + requestFeedbackStore.Add(model); + } +} diff --git a/src/Code311.Tabler.Mvc/Helpers/HtmlHelperExtensions.cs b/src/Code311.Tabler.Mvc/Helpers/HtmlHelperExtensions.cs new file mode 100644 index 0000000..e99015d --- /dev/null +++ b/src/Code311.Tabler.Mvc/Helpers/HtmlHelperExtensions.cs @@ -0,0 +1,67 @@ +using Code311.Tabler.Mvc.Assets; +using Code311.Tabler.Mvc.Feedback; +using Code311.Tabler.Mvc.Theming; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Mvc.Helpers; + +/// +/// Provides lightweight MVC HTML helper extensions for Code311 host integration. +/// +/// +/// Extensions are optional ergonomics helpers and do not alter framework-neutral core contracts. +/// +public static class HtmlHelperExtensions +{ + /// + /// Registers a script asset for the current request. + /// + /// The HTML helper. + /// The script path. + public static void AddCode311Script(this IHtmlHelper html, string path) + { + var store = html.ViewContext.HttpContext.RequestServices.GetRequiredService(); + store.AddScript(path); + } + + /// + /// Registers a style asset for the current request. + /// + /// The HTML helper. + /// The style path. + public static void AddCode311Style(this IHtmlHelper html, string path) + { + var store = html.ViewContext.HttpContext.RequestServices.GetRequiredService(); + store.AddStyle(path); + } + + /// + /// Publishes request-scoped feedback. + /// + /// The HTML helper. + /// The semantic tone. + /// The message text. + public static void PublishCode311Feedback(this IHtmlHelper html, UiTone tone, string message) + { + var services = html.ViewContext.HttpContext.RequestServices; + var feedback = services.GetRequiredService(); + var scoped = services.GetRequiredService(); + var model = new FeedbackMessage(tone, message, null, DateTimeOffset.UtcNow); + feedback.Publish(model); + scoped.Add(model); + } + + /// + /// Gets the currently resolved theme name for the request. + /// + /// The HTML helper. + /// The theme name, or default when no request theme has been resolved. + public static string GetCode311ThemeName(this IHtmlHelper html) + { + var context = html.ViewContext.HttpContext.RequestServices.GetRequiredService(); + return context.Current?.Name ?? "default"; + } +} diff --git a/src/Code311.Tabler.Mvc/Properties/AssemblyInfo.cs b/src/Code311.Tabler.Mvc/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..7acdc69 --- /dev/null +++ b/src/Code311.Tabler.Mvc/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Code311.Tests.Integration.Mvc")] diff --git a/src/Code311.Tabler.Mvc/Theming/ThemeRequestContext.cs b/src/Code311.Tabler.Mvc/Theming/ThemeRequestContext.cs new file mode 100644 index 0000000..5dd61bb --- /dev/null +++ b/src/Code311.Tabler.Mvc/Theming/ThemeRequestContext.cs @@ -0,0 +1,22 @@ +using Code311.Ui.Abstractions.Theming; + +namespace Code311.Tabler.Mvc.Theming; + +/// +/// Provides access to the current request theme profile. +/// +/// +/// Theme context is request-scoped and set by MVC filters. +/// +public interface ICode311ThemeRequestContext +{ + /// + /// Gets or sets the current request theme profile. + /// + ThemeProfile? Current { get; set; } +} + +internal sealed class Code311ThemeRequestContext : ICode311ThemeRequestContext +{ + public ThemeProfile? Current { get; set; } +} diff --git a/src/Code311.Tabler.Razor/.gitkeep b/src/Code311.Tabler.Razor/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Razor/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Razor/Assets/AssetModels.cs b/src/Code311.Tabler.Razor/Assets/AssetModels.cs new file mode 100644 index 0000000..39025ca --- /dev/null +++ b/src/Code311.Tabler.Razor/Assets/AssetModels.cs @@ -0,0 +1,37 @@ +namespace Code311.Tabler.Razor.Assets; + +/// +/// Represents a scoped asset entry for Razor request rendering. +/// +/// The asset path. +/// True when the asset is a script. +public sealed record Code311ScopedAsset(string Path, bool IsScript); + +/// +/// Stores request-scoped assets for Razor Pages. +/// +public interface ICode311AssetRequestStore +{ + void AddStyle(string path); + void AddScript(string path); + IReadOnlyList GetAll(); +} + +internal sealed class Code311AssetRequestStore : ICode311AssetRequestStore +{ + private readonly List _assets = []; + + public void AddStyle(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _assets.Add(new Code311ScopedAsset(path, false)); + } + + public void AddScript(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _assets.Add(new Code311ScopedAsset(path, true)); + } + + public IReadOnlyList GetAll() => _assets; +} diff --git a/src/Code311.Tabler.Razor/Assets/WidgetAssetRequestStoreExtensions.cs b/src/Code311.Tabler.Razor/Assets/WidgetAssetRequestStoreExtensions.cs new file mode 100644 index 0000000..0877304 --- /dev/null +++ b/src/Code311.Tabler.Razor/Assets/WidgetAssetRequestStoreExtensions.cs @@ -0,0 +1,25 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Widgets; + +namespace Code311.Tabler.Razor.Assets; + +public static class WidgetAssetRequestStoreExtensions +{ + public static void AddWidgetAssets(this ICode311AssetRequestStore store, ITablerWidgetSlotParticipant widget) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(widget); + + foreach (var asset in widget.GetAssetContributions().OrderBy(a => a.Order)) + { + if (asset.Type == TablerAssetType.Script) + { + store.AddScript(asset.Path); + } + else + { + store.AddStyle(asset.Path); + } + } + } +} diff --git a/src/Code311.Tabler.Razor/Code311.Tabler.Razor.csproj b/src/Code311.Tabler.Razor/Code311.Tabler.Razor.csproj new file mode 100644 index 0000000..9e46c86 --- /dev/null +++ b/src/Code311.Tabler.Razor/Code311.Tabler.Razor.csproj @@ -0,0 +1,17 @@ + + + net10.0 + Code311.Tabler.Razor + Code311.Tabler.Razor + Razor Pages integration adapter for Code311 Tabler packages. + Code311.Tabler.Razor + + + + + + + + + + diff --git a/src/Code311.Tabler.Razor/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Razor/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..001e4f8 --- /dev/null +++ b/src/Code311.Tabler.Razor/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using Code311.Tabler.Components.DependencyInjection; +using Code311.Tabler.Core.DependencyInjection; +using Code311.Tabler.Razor.Assets; +using Code311.Tabler.Razor.Feedback; +using Code311.Tabler.Razor.Filters; +using Code311.Tabler.Razor.Theming; +using Code311.Ui.Core.DependencyInjection; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Code311.Tabler.Razor.DependencyInjection; + +/// +/// Provides Razor Pages adapter registration helpers for Code311 Tabler integration. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Code311 Tabler Razor integration services. + /// + public static IServiceCollection AddCode311TablerRazor(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddCode311UiCore(); + services.AddCode311TablerCore(); + services.AddCode311TablerComponents(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddHttpContextAccessor(); + services.AddTransient, ConfigureCode311RazorOptions>(); + + return services; + } +} + +internal sealed class ConfigureCode311RazorOptions : IConfigureOptions +{ + public void Configure(MvcOptions options) + { + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + } +} diff --git a/src/Code311.Tabler.Razor/Feedback/RequestFeedback.cs b/src/Code311.Tabler.Razor/Feedback/RequestFeedback.cs new file mode 100644 index 0000000..bebb3d2 --- /dev/null +++ b/src/Code311.Tabler.Razor/Feedback/RequestFeedback.cs @@ -0,0 +1,25 @@ +using Code311.Ui.Core.Feedback; + +namespace Code311.Tabler.Razor.Feedback; + +/// +/// Stores request-scoped feedback messages for Razor Pages. +/// +public interface ICode311RequestFeedbackStore +{ + void Add(FeedbackMessage message); + IReadOnlyList GetAll(); +} + +internal sealed class Code311RequestFeedbackStore : ICode311RequestFeedbackStore +{ + private readonly List _messages = []; + + public void Add(FeedbackMessage message) + { + ArgumentNullException.ThrowIfNull(message); + _messages.Add(message); + } + + public IReadOnlyList GetAll() => _messages; +} diff --git a/src/Code311.Tabler.Razor/Filters/Code311RazorFilters.cs b/src/Code311.Tabler.Razor/Filters/Code311RazorFilters.cs new file mode 100644 index 0000000..dccdda1 --- /dev/null +++ b/src/Code311.Tabler.Razor/Filters/Code311RazorFilters.cs @@ -0,0 +1,76 @@ +using Code311.Tabler.Razor.Feedback; +using Code311.Tabler.Razor.Theming; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Code311.Tabler.Razor.Filters; + +/// +/// Captures Razor Page model validation errors into Code311 feedback channels. +/// +public sealed class Code311PageFeedbackFilter( + IFeedbackChannel feedbackChannel, + ICode311RequestFeedbackStore requestFeedbackStore) : IAsyncPageFilter +{ + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) => Task.CompletedTask; + + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + if (!context.ModelState.IsValid) + { + foreach (var kvp in context.ModelState) + { + foreach (var err in kvp.Value?.Errors ?? []) + { + var message = new FeedbackMessage(UiTone.Danger, err.ErrorMessage, "MODEL_STATE", DateTimeOffset.UtcNow); + feedbackChannel.Publish(message); + requestFeedbackStore.Add(message); + } + } + } + + await next().ConfigureAwait(false); + } +} + +/// +/// Coordinates request-scope busy/preloader transitions for Razor handlers. +/// +public sealed class Code311BusyTransitionPageFilter( + IBusyStateCoordinator busyStateCoordinator, + IPreloaderOrchestrator preloaderOrchestrator) : IAsyncPageFilter +{ + private const string RequestScope = "razor-request"; + + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) => Task.CompletedTask; + + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + using var scope = busyStateCoordinator.EnterScope(RequestScope); + preloaderOrchestrator.Start(RequestScope); + await next().ConfigureAwait(false); + preloaderOrchestrator.Complete(RequestScope); + } +} + +/// +/// Resolves request theme for Razor Pages and stores it in request context. +/// +public sealed class Code311ThemePageFilter( + IThemeProfileResolver themeProfileResolver, + ICode311ThemeRequestContext themeRequestContext) : IAsyncPageFilter +{ + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) => Task.CompletedTask; + + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + var requestedTheme = context.HttpContext.Request.Query["theme"].FirstOrDefault() ?? "default"; + themeRequestContext.Current = await themeProfileResolver.ResolveAsync(requestedTheme, context.HttpContext.RequestAborted) + .ConfigureAwait(false); + await next().ConfigureAwait(false); + } +} diff --git a/src/Code311.Tabler.Razor/Helpers/Code311PageModelBase.cs b/src/Code311.Tabler.Razor/Helpers/Code311PageModelBase.cs new file mode 100644 index 0000000..1dce866 --- /dev/null +++ b/src/Code311.Tabler.Razor/Helpers/Code311PageModelBase.cs @@ -0,0 +1,22 @@ +using Code311.Tabler.Razor.Feedback; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Code311.Tabler.Razor.Helpers; + +/// +/// Optional lightweight base type for Code311-enabled Razor Pages. +/// +public abstract class Code311PageModelBase(IFeedbackChannel feedbackChannel, ICode311RequestFeedbackStore requestFeedbackStore) : PageModel +{ + /// + /// Publishes semantic feedback for current request. + /// + protected void PublishFeedback(UiTone tone, string message) + { + var model = new FeedbackMessage(tone, message, null, DateTimeOffset.UtcNow); + feedbackChannel.Publish(model); + requestFeedbackStore.Add(model); + } +} diff --git a/src/Code311.Tabler.Razor/Helpers/PageModelExtensions.cs b/src/Code311.Tabler.Razor/Helpers/PageModelExtensions.cs new file mode 100644 index 0000000..ee39aa9 --- /dev/null +++ b/src/Code311.Tabler.Razor/Helpers/PageModelExtensions.cs @@ -0,0 +1,46 @@ +using Code311.Tabler.Razor.Assets; +using Code311.Tabler.Razor.Feedback; +using Code311.Tabler.Razor.Theming; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Razor.Helpers; + +/// +/// Provides lightweight Razor Pages helper extensions for Code311 integration. +/// +public static class PageModelExtensions +{ + /// + /// Publishes semantic feedback for the current request. + /// + public static void PublishCode311Feedback(this PageModel page, UiTone tone, string message) + { + var services = page.HttpContext.RequestServices; + var channel = services.GetRequiredService(); + var scoped = services.GetRequiredService(); + var model = new FeedbackMessage(tone, message, null, DateTimeOffset.UtcNow); + channel.Publish(model); + scoped.Add(model); + } + + /// + /// Adds a script asset to request scope. + /// + public static void AddCode311Script(this PageModel page, string path) + => page.HttpContext.RequestServices.GetRequiredService().AddScript(path); + + /// + /// Adds a style asset to request scope. + /// + public static void AddCode311Style(this PageModel page, string path) + => page.HttpContext.RequestServices.GetRequiredService().AddStyle(path); + + /// + /// Gets the active request theme name. + /// + public static string GetCode311ThemeName(this PageModel page) + => page.HttpContext.RequestServices.GetRequiredService().Current?.Name ?? "default"; +} diff --git a/src/Code311.Tabler.Razor/Properties/AssemblyInfo.cs b/src/Code311.Tabler.Razor/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..cb10dbb --- /dev/null +++ b/src/Code311.Tabler.Razor/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Code311.Tests.Integration.Razor")] diff --git a/src/Code311.Tabler.Razor/Theming/ThemeRequestContext.cs b/src/Code311.Tabler.Razor/Theming/ThemeRequestContext.cs new file mode 100644 index 0000000..e745fe7 --- /dev/null +++ b/src/Code311.Tabler.Razor/Theming/ThemeRequestContext.cs @@ -0,0 +1,16 @@ +using Code311.Ui.Abstractions.Theming; + +namespace Code311.Tabler.Razor.Theming; + +/// +/// Provides access to request-scoped theme context for Razor Pages. +/// +public interface ICode311ThemeRequestContext +{ + ThemeProfile? Current { get; set; } +} + +internal sealed class Code311ThemeRequestContext : ICode311ThemeRequestContext +{ + public ThemeProfile? Current { get; set; } +} diff --git a/src/Code311.Tabler.Widgets.Calendar/.gitkeep b/src/Code311.Tabler.Widgets.Calendar/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Widgets.Calendar/Assets/CalendarWidgetAssets.cs b/src/Code311.Tabler.Widgets.Calendar/Assets/CalendarWidgetAssets.cs new file mode 100644 index 0000000..209a7f9 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/Assets/CalendarWidgetAssets.cs @@ -0,0 +1,17 @@ +using Code311.Tabler.Core.Assets; + +namespace Code311.Tabler.Widgets.Calendar.Assets; + +internal static class CalendarWidgetAssets +{ + public static readonly IReadOnlyList Assets = + [ + new("_content/Code311.Tabler.Widgets.Calendar/css/calendar.tabler.min.css", TablerAssetType.Style, 120), + new("_content/Code311.Tabler.Widgets.Calendar/js/calendar.tabler.min.js", TablerAssetType.Script, 130) + ]; +} + +internal sealed class CalendarAssetManifestProvider : ITablerAssetManifestProvider +{ + public IReadOnlyList GetAssets() => CalendarWidgetAssets.Assets; +} diff --git a/src/Code311.Tabler.Widgets.Calendar/Code311.Tabler.Widgets.Calendar.csproj b/src/Code311.Tabler.Widgets.Calendar/Code311.Tabler.Widgets.Calendar.csproj new file mode 100644 index 0000000..a750a13 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/Code311.Tabler.Widgets.Calendar.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Code311.Tabler.Widgets.Calendar + Code311.Tabler.Widgets.Calendar + Calendar integration lifecycle package for Code311 Tabler widgets. + Code311.Tabler.Widgets.Calendar + + + + + + diff --git a/src/Code311.Tabler.Widgets.Calendar/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Widgets.Calendar/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0b9ec01 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.Calendar.Assets; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Widgets.Calendar.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCode311TablerCalendarWidgets(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); + return services; + } +} diff --git a/src/Code311.Tabler.Widgets.Calendar/Options/CalendarWidgetOptions.cs b/src/Code311.Tabler.Widgets.Calendar/Options/CalendarWidgetOptions.cs new file mode 100644 index 0000000..c2698fc --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/Options/CalendarWidgetOptions.cs @@ -0,0 +1,31 @@ +namespace Code311.Tabler.Widgets.Calendar.Options; + +public sealed record CalendarWidgetOptions(string InitialView = "dayGridMonth", bool WeekendsVisible = true, bool Editable = false); + +public sealed class CalendarWidgetOptionsBuilder +{ + private string _initialView = "dayGridMonth"; + private bool _weekendsVisible = true; + private bool _editable; + + public CalendarWidgetOptionsBuilder WithInitialView(string view) + { + ArgumentException.ThrowIfNullOrWhiteSpace(view); + _initialView = view; + return this; + } + + public CalendarWidgetOptionsBuilder ShowWeekends(bool visible = true) + { + _weekendsVisible = visible; + return this; + } + + public CalendarWidgetOptionsBuilder AllowEditing(bool editable = true) + { + _editable = editable; + return this; + } + + public CalendarWidgetOptions Build() => new(_initialView, _weekendsVisible, _editable); +} diff --git a/src/Code311.Tabler.Widgets.Calendar/Widgets/CalendarWidgetSlot.cs b/src/Code311.Tabler.Widgets.Calendar/Widgets/CalendarWidgetSlot.cs new file mode 100644 index 0000000..ea2cb0c --- /dev/null +++ b/src/Code311.Tabler.Widgets.Calendar/Widgets/CalendarWidgetSlot.cs @@ -0,0 +1,26 @@ +using Code311.Tabler.Core.Widgets; +using Code311.Tabler.Widgets.Calendar.Assets; +using Code311.Tabler.Widgets.Calendar.Options; + +namespace Code311.Tabler.Widgets.Calendar.Widgets; + +public sealed class CalendarWidgetSlot(string widgetKey, string slotIntent, CalendarWidgetOptions options) : ITablerWidgetSlotParticipant +{ + public TablerWidgetSlotDefinition Slot { get; } = new( + widgetKey, + slotIntent, + new TablerWidgetOptionsEnvelope(new Dictionary + { + ["initialView"] = options.InitialView, + ["weekendsVisible"] = options.WeekendsVisible, + ["editable"] = options.Editable + })); + + public IReadOnlyList GetAssetContributions() => CalendarWidgetAssets.Assets; + + public TablerWidgetInitializationRequest CreateInitialization(string elementId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(elementId); + return new TablerWidgetInitializationRequest(Slot.WidgetKey, elementId, TablerWidgetInitializationSerializer.Serialize(Slot.Options)); + } +} diff --git a/src/Code311.Tabler.Widgets.Charts/.gitkeep b/src/Code311.Tabler.Widgets.Charts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Widgets.Charts/Assets/ChartWidgetAssets.cs b/src/Code311.Tabler.Widgets.Charts/Assets/ChartWidgetAssets.cs new file mode 100644 index 0000000..3cfbb4b --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/Assets/ChartWidgetAssets.cs @@ -0,0 +1,17 @@ +using Code311.Tabler.Core.Assets; + +namespace Code311.Tabler.Widgets.Charts.Assets; + +internal static class ChartWidgetAssets +{ + public static readonly IReadOnlyList Assets = + [ + new("_content/Code311.Tabler.Widgets.Charts/css/charts.tabler.min.css", TablerAssetType.Style, 140), + new("_content/Code311.Tabler.Widgets.Charts/js/charts.tabler.min.js", TablerAssetType.Script, 150) + ]; +} + +internal sealed class ChartAssetManifestProvider : ITablerAssetManifestProvider +{ + public IReadOnlyList GetAssets() => ChartWidgetAssets.Assets; +} diff --git a/src/Code311.Tabler.Widgets.Charts/Code311.Tabler.Widgets.Charts.csproj b/src/Code311.Tabler.Widgets.Charts/Code311.Tabler.Widgets.Charts.csproj new file mode 100644 index 0000000..955ca5a --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/Code311.Tabler.Widgets.Charts.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Code311.Tabler.Widgets.Charts + Code311.Tabler.Widgets.Charts + Charts integration lifecycle package for Code311 Tabler widgets. + Code311.Tabler.Widgets.Charts + + + + + + diff --git a/src/Code311.Tabler.Widgets.Charts/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Widgets.Charts/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a697f31 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.Charts.Assets; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Widgets.Charts.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCode311TablerChartWidgets(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); + return services; + } +} diff --git a/src/Code311.Tabler.Widgets.Charts/Options/ChartWidgetOptions.cs b/src/Code311.Tabler.Widgets.Charts/Options/ChartWidgetOptions.cs new file mode 100644 index 0000000..35e7c94 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/Options/ChartWidgetOptions.cs @@ -0,0 +1,31 @@ +namespace Code311.Tabler.Widgets.Charts.Options; + +public sealed record ChartWidgetOptions(string ChartType = "line", bool LegendVisible = true, bool Responsive = true); + +public sealed class ChartWidgetOptionsBuilder +{ + private string _chartType = "line"; + private bool _legendVisible = true; + private bool _responsive = true; + + public ChartWidgetOptionsBuilder WithType(string chartType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(chartType); + _chartType = chartType; + return this; + } + + public ChartWidgetOptionsBuilder ShowLegend(bool visible = true) + { + _legendVisible = visible; + return this; + } + + public ChartWidgetOptionsBuilder UseResponsiveLayout(bool enabled = true) + { + _responsive = enabled; + return this; + } + + public ChartWidgetOptions Build() => new(_chartType, _legendVisible, _responsive); +} diff --git a/src/Code311.Tabler.Widgets.Charts/Widgets/ChartWidgetSlot.cs b/src/Code311.Tabler.Widgets.Charts/Widgets/ChartWidgetSlot.cs new file mode 100644 index 0000000..04e3804 --- /dev/null +++ b/src/Code311.Tabler.Widgets.Charts/Widgets/ChartWidgetSlot.cs @@ -0,0 +1,26 @@ +using Code311.Tabler.Core.Widgets; +using Code311.Tabler.Widgets.Charts.Assets; +using Code311.Tabler.Widgets.Charts.Options; + +namespace Code311.Tabler.Widgets.Charts.Widgets; + +public sealed class ChartWidgetSlot(string widgetKey, string slotIntent, ChartWidgetOptions options) : ITablerWidgetSlotParticipant +{ + public TablerWidgetSlotDefinition Slot { get; } = new( + widgetKey, + slotIntent, + new TablerWidgetOptionsEnvelope(new Dictionary + { + ["chartType"] = options.ChartType, + ["legendVisible"] = options.LegendVisible, + ["responsive"] = options.Responsive + })); + + public IReadOnlyList GetAssetContributions() => ChartWidgetAssets.Assets; + + public TablerWidgetInitializationRequest CreateInitialization(string elementId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(elementId); + return new TablerWidgetInitializationRequest(Slot.WidgetKey, elementId, TablerWidgetInitializationSerializer.Serialize(Slot.Options)); + } +} diff --git a/src/Code311.Tabler.Widgets.DataTables/.gitkeep b/src/Code311.Tabler.Widgets.DataTables/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Tabler.Widgets.DataTables/Assets/DataTableWidgetAssets.cs b/src/Code311.Tabler.Widgets.DataTables/Assets/DataTableWidgetAssets.cs new file mode 100644 index 0000000..60351ba --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/Assets/DataTableWidgetAssets.cs @@ -0,0 +1,17 @@ +using Code311.Tabler.Core.Assets; + +namespace Code311.Tabler.Widgets.DataTables.Assets; + +internal static class DataTableWidgetAssets +{ + public static readonly IReadOnlyList Assets = + [ + new("_content/Code311.Tabler.Widgets.DataTables/css/datatables.tabler.min.css", TablerAssetType.Style, 100), + new("_content/Code311.Tabler.Widgets.DataTables/js/datatables.tabler.min.js", TablerAssetType.Script, 110) + ]; +} + +internal sealed class DataTableAssetManifestProvider : ITablerAssetManifestProvider +{ + public IReadOnlyList GetAssets() => DataTableWidgetAssets.Assets; +} diff --git a/src/Code311.Tabler.Widgets.DataTables/Code311.Tabler.Widgets.DataTables.csproj b/src/Code311.Tabler.Widgets.DataTables/Code311.Tabler.Widgets.DataTables.csproj new file mode 100644 index 0000000..7f7d306 --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/Code311.Tabler.Widgets.DataTables.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Code311.Tabler.Widgets.DataTables + Code311.Tabler.Widgets.DataTables + DataTables integration lifecycle package for Code311 Tabler widgets. + Code311.Tabler.Widgets.DataTables + + + + + + diff --git a/src/Code311.Tabler.Widgets.DataTables/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Tabler.Widgets.DataTables/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..94e214c --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.DataTables.Assets; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Tabler.Widgets.DataTables.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCode311TablerDataTablesWidgets(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); + return services; + } +} diff --git a/src/Code311.Tabler.Widgets.DataTables/Options/DataTableWidgetOptions.cs b/src/Code311.Tabler.Widgets.DataTables/Options/DataTableWidgetOptions.cs new file mode 100644 index 0000000..bb6fa8e --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/Options/DataTableWidgetOptions.cs @@ -0,0 +1,51 @@ +namespace Code311.Tabler.Widgets.DataTables.Options; + +public sealed record DataTableWidgetOptions( + int PageLength = 25, + bool SearchEnabled = true, + bool OrderingEnabled = true, + string? DefaultSortColumn = null, + string DefaultSortDirection = "asc"); + +public sealed class DataTableWidgetOptionsBuilder +{ + private int _pageLength = 25; + private bool _searchEnabled = true; + private bool _orderingEnabled = true; + private string? _defaultSortColumn; + private string _defaultSortDirection = "asc"; + + public DataTableWidgetOptionsBuilder WithPageLength(int pageLength) + { + if (pageLength <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageLength)); + } + + _pageLength = pageLength; + return this; + } + + public DataTableWidgetOptionsBuilder EnableSearch(bool enabled = true) + { + _searchEnabled = enabled; + return this; + } + + public DataTableWidgetOptionsBuilder EnableOrdering(bool enabled = true) + { + _orderingEnabled = enabled; + return this; + } + + public DataTableWidgetOptionsBuilder WithDefaultSort(string column, string direction = "asc") + { + ArgumentException.ThrowIfNullOrWhiteSpace(column); + _defaultSortColumn = column; + _defaultSortDirection = direction.Equals("desc", StringComparison.OrdinalIgnoreCase) ? "desc" : "asc"; + return this; + } + + public DataTableWidgetOptions Build() + => new(_pageLength, _searchEnabled, _orderingEnabled, _defaultSortColumn, _defaultSortDirection); +} diff --git a/src/Code311.Tabler.Widgets.DataTables/Widgets/DataTableWidgetSlot.cs b/src/Code311.Tabler.Widgets.DataTables/Widgets/DataTableWidgetSlot.cs new file mode 100644 index 0000000..eaebcb0 --- /dev/null +++ b/src/Code311.Tabler.Widgets.DataTables/Widgets/DataTableWidgetSlot.cs @@ -0,0 +1,28 @@ +using Code311.Tabler.Core.Widgets; +using Code311.Tabler.Widgets.DataTables.Assets; +using Code311.Tabler.Widgets.DataTables.Options; + +namespace Code311.Tabler.Widgets.DataTables.Widgets; + +public sealed class DataTableWidgetSlot(string widgetKey, string slotIntent, DataTableWidgetOptions options) : ITablerWidgetSlotParticipant +{ + public TablerWidgetSlotDefinition Slot { get; } = new( + widgetKey, + slotIntent, + new TablerWidgetOptionsEnvelope(new Dictionary + { + ["pageLength"] = options.PageLength, + ["searchEnabled"] = options.SearchEnabled, + ["orderingEnabled"] = options.OrderingEnabled, + ["defaultSortColumn"] = options.DefaultSortColumn, + ["defaultSortDirection"] = options.DefaultSortDirection + })); + + public IReadOnlyList GetAssetContributions() => DataTableWidgetAssets.Assets; + + public TablerWidgetInitializationRequest CreateInitialization(string elementId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(elementId); + return new TablerWidgetInitializationRequest(Slot.WidgetKey, elementId, TablerWidgetInitializationSerializer.Serialize(Slot.Options)); + } +} diff --git a/src/Code311.Ui.Abstractions/Code311.Ui.Abstractions.csproj b/src/Code311.Ui.Abstractions/Code311.Ui.Abstractions.csproj new file mode 100644 index 0000000..d7f4edb --- /dev/null +++ b/src/Code311.Ui.Abstractions/Code311.Ui.Abstractions.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Code311.Ui.Abstractions + Code311.Ui.Abstractions + Design-system-neutral contracts for the Code311 UI framework. + Code311.Ui.Abstractions + + diff --git a/src/Code311.Ui.Abstractions/Internal/Contracts/InternalContractCatalog.cs b/src/Code311.Ui.Abstractions/Internal/Contracts/InternalContractCatalog.cs new file mode 100644 index 0000000..dc44a10 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Internal/Contracts/InternalContractCatalog.cs @@ -0,0 +1,50 @@ +namespace Code311.Ui.Abstractions.Internal.Contracts; + +/// +/// Maps form semantics to Tabler render classes. +/// +/// +/// Tabler mappers are intentionally kept in abstractions so Code311.Tabler.Core can implement +/// mappings while public API semantics remain design-system-neutral. +/// +public interface ITablerFormClassMapper; + +/// +/// Maps navigation semantics to Tabler render classes. +/// +/// +/// Mapping contracts isolate Tabler specifics from consumer-facing component APIs. +/// +public interface ITablerNavigationClassMapper; + +/// +/// Maps layout semantics to Tabler render classes. +/// +/// +/// Layout mapping contracts are consumed by Tabler implementation packages only. +/// +public interface ITablerLayoutClassMapper; + +/// +/// Maps feedback semantics to Tabler render classes. +/// +/// +/// Feedback mappings provide deterministic intent-to-style translation. +/// +public interface ITablerFeedbackClassMapper; + +/// +/// Maps data semantics to Tabler render classes. +/// +/// +/// Data mappings support semantic rendering for tables, badges, and KPI visuals. +/// +public interface ITablerDataClassMapper; + +/// +/// Maps media semantics to Tabler render classes. +/// +/// +/// Media mappings apply to semantic avatar, image, banner, and file-preview components. +/// +public interface ITablerMediaClassMapper; diff --git a/src/Code311.Ui.Abstractions/Options/UiFrameworkOptions.cs b/src/Code311.Ui.Abstractions/Options/UiFrameworkOptions.cs new file mode 100644 index 0000000..3884006 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Options/UiFrameworkOptions.cs @@ -0,0 +1,36 @@ +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Ui.Abstractions.Options; + +/// +/// Represents global framework behavior options. +/// +/// +/// Hosts can configure these options to establish predictable defaults. +/// +public sealed record UiFrameworkOptions +{ + /// + /// Gets the default density. + /// + /// + /// Components should use this value when no explicit density is supplied. + /// + public UiDensity DefaultDensity { get; init; } = UiDensity.Comfortable; + + /// + /// Gets the default appearance. + /// + /// + /// Appearance defaults are semantic and design-system-neutral. + /// + public UiAppearance DefaultAppearance { get; init; } = UiAppearance.Soft; + + /// + /// Gets the default page size. + /// + /// + /// Data-oriented components can use this value when no explicit size is provided. + /// + public int DefaultPageSize { get; init; } = 25; +} diff --git a/src/Code311.Ui.Abstractions/Preferences/PreferenceContracts.cs b/src/Code311.Ui.Abstractions/Preferences/PreferenceContracts.cs new file mode 100644 index 0000000..10cdad2 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Preferences/PreferenceContracts.cs @@ -0,0 +1,33 @@ +namespace Code311.Ui.Abstractions.Preferences; + +/// +/// Persists and retrieves user UI preferences. +/// +/// +/// Persistence implementations are expected in infrastructure packages and must support tenant-aware operations. +/// +public interface IUserUiPreferenceStore +{ + /// + /// Gets a user's preferences. + /// + /// The tenant identifier. + /// The user identifier. + /// The cancellation token. + /// The user preference, or when none exists. + /// + /// Retrieval should be side-effect free and safe for repeated invocation. + /// + Task GetAsync(string tenantId, string userId, CancellationToken cancellationToken = default); + + /// + /// Upserts a user's preference record. + /// + /// The preference model. + /// The cancellation token. + /// A task representing completion. + /// + /// Implementations should treat and as a composite key. + /// + Task UpsertAsync(UserUiPreference preference, CancellationToken cancellationToken = default); +} diff --git a/src/Code311.Ui.Abstractions/Preferences/UserUiPreference.cs b/src/Code311.Ui.Abstractions/Preferences/UserUiPreference.cs new file mode 100644 index 0000000..0caa487 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Preferences/UserUiPreference.cs @@ -0,0 +1,84 @@ +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Ui.Abstractions.Preferences; + +/// +/// Represents a persisted user interface preference set. +/// +/// +/// This model is tenant-aware and provider-neutral so persistence packages can implement storage without UI coupling. +/// +public sealed record UserUiPreference +{ + /// + /// Gets the tenant identifier for multi-tenant partitioning. + /// + /// + /// Implementations should treat this value as required for tenant-aware data segregation. + /// + public required string TenantId { get; init; } + + /// + /// Gets the user identifier. + /// + /// + /// The identifier format is application-defined and intentionally opaque to the framework. + /// + public required string UserId { get; init; } + + /// + /// Gets the selected theme profile name. + /// + /// + /// The value should match a registered ThemeProfile name when possible. + /// + public required string Theme { get; init; } + + /// + /// Gets the preferred density. + /// + /// + /// Density is used by layout and control rendering services. + /// + public UiDensity Density { get; init; } = UiDensity.Comfortable; + + /// + /// Gets a value indicating whether the sidebar is collapsed. + /// + /// + /// Sidebar rendering packages can use this value as initial navigation state. + /// + public bool SidebarCollapsed { get; init; } + + /// + /// Gets the default page size for list/data views. + /// + /// + /// Consumers can ignore this value for pages that require fixed server-side paging. + /// + public int DefaultPageSize { get; init; } = 25; + + /// + /// Gets the preferred language code. + /// + /// + /// The value should follow BCP-47 conventions where possible. + /// + public string Language { get; init; } = "en"; + + /// + /// Gets the preferred time zone identifier. + /// + /// + /// The exact identifier system is left to host/application policy. + /// + public string TimeZone { get; init; } = "UTC"; + + /// + /// Gets the UTC timestamp when the preference row was last updated. + /// + /// + /// Persistence implementations should set this value at write time. + /// + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/src/Code311.Ui.Abstractions/README.md b/src/Code311.Ui.Abstractions/README.md new file mode 100644 index 0000000..3134744 --- /dev/null +++ b/src/Code311.Ui.Abstractions/README.md @@ -0,0 +1,3 @@ +# Code311.Ui.Abstractions + +Design-system-neutral contracts for the Code311 UI framework ecosystem. diff --git a/src/Code311.Ui.Abstractions/Semantics/UiSemantics.cs b/src/Code311.Ui.Abstractions/Semantics/UiSemantics.cs new file mode 100644 index 0000000..d437c56 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Semantics/UiSemantics.cs @@ -0,0 +1,122 @@ +namespace Code311.Ui.Abstractions.Semantics; + +/// +/// Represents semantic visual tone independent of any CSS framework. +/// +/// +/// Tone values communicate intent (success, warning, etc.) and are mapped internally by design-system packages. +/// +public enum UiTone +{ + Neutral, + Accent, + Success, + Warning, + Danger, + Info +} + +/// +/// Represents semantic component appearance variants. +/// +/// +/// Appearance values are stable public API semantics and must never expose design-system tokens. +/// +public enum UiAppearance +{ + Solid, + Soft, + Outline, + Ghost, + Link +} + +/// +/// Represents semantic density options for layout and controls. +/// +/// +/// Density drives spacing and compactness behavior in a design-system-neutral manner. +/// +public enum UiDensity +{ + Compact, + Comfortable, + Spacious +} + +/// +/// Represents semantic component sizing options. +/// +/// +/// Size values should be interpreted by design-system adapters using local mapping policies. +/// +public enum UiSize +{ + Small, + Medium, + Large +} + +/// +/// Represents semantic UI state used by controls and patterns. +/// +/// +/// State values are intentionally generic so multiple components can share the same contract. +/// +public enum UiState +{ + Default, + Active, + Disabled, + ReadOnly, + Busy, + Hidden +} + +/// +/// Represents semantic placement intent for overlays and anchored elements. +/// +/// +/// Placement abstractions allow adapters to map into framework-specific placement primitives. +/// +public enum UiPlacement +{ + Top, + TopStart, + TopEnd, + Right, + Bottom, + BottomStart, + BottomEnd, + Left, + Center +} + +/// +/// Represents semantic layout intent for containers and grouping primitives. +/// +/// +/// Layout semantics intentionally avoid concrete CSS display or grid syntax. +/// +public enum UiLayout +{ + Inline, + Stack, + Grid, + Split, + Fill +} + +/// +/// Represents semantic pinning behavior. +/// +/// +/// Pinning indicates sticky/fixed intent without exposing implementation details. +/// +public enum UiPinned +{ + None, + Start, + End, + Both +} diff --git a/src/Code311.Ui.Abstractions/Theming/ThemeContracts.cs b/src/Code311.Ui.Abstractions/Theming/ThemeContracts.cs new file mode 100644 index 0000000..b0094ea --- /dev/null +++ b/src/Code311.Ui.Abstractions/Theming/ThemeContracts.cs @@ -0,0 +1,21 @@ +namespace Code311.Ui.Abstractions.Theming; + +/// +/// Resolves active theme profiles. +/// +/// +/// Theme resolution can consider tenant policy and user preference state. +/// +public interface IThemeProfileResolver +{ + /// + /// Resolves a theme profile by name. + /// + /// The theme name. + /// The cancellation token. + /// The resolved theme profile, or when not found. + /// + /// Implementations should avoid throwing for unknown themes and return null instead. + /// + Task ResolveAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Code311.Ui.Abstractions/Theming/ThemeModels.cs b/src/Code311.Ui.Abstractions/Theming/ThemeModels.cs new file mode 100644 index 0000000..6f84a55 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Theming/ThemeModels.cs @@ -0,0 +1,87 @@ +using Code311.Ui.Abstractions.Semantics; + +namespace Code311.Ui.Abstractions.Theming; + +/// +/// Represents a named and reusable semantic theme profile. +/// +/// +/// Theme profiles are consumed by runtime services and can be persisted as user preferences. +/// +public sealed record ThemeProfile +{ + /// + /// Gets the unique theme profile name. + /// + /// + /// Names are expected to be stable identifiers for persistence and transport. + /// + public required string Name { get; init; } + + /// + /// Gets the semantic primary tone for the profile. + /// + /// + /// The actual color mapping is design-system-specific and occurs outside abstractions. + /// + public UiTone Tone { get; init; } = UiTone.Neutral; + + /// + /// Gets the semantic density preset. + /// + /// + /// Density influences spacing rules in components and layout helpers. + /// + public UiDensity Density { get; init; } = UiDensity.Comfortable; + + /// + /// Gets the semantic sidebar mode. + /// + /// + /// Sidebars can be expanded/collapsed/overlay depending on adapter interpretation. + /// + public SidebarMode SidebarMode { get; init; } = SidebarMode.Expanded; + + /// + /// Gets the semantic navbar style. + /// + /// + /// Navbar style remains framework-neutral and should not reference concrete class names. + /// + public NavbarStyle NavbarStyle { get; init; } = NavbarStyle.Default; + + /// + /// Gets a value indicating whether dark mode is enabled. + /// + /// + /// Runtime adapters can honor this flag according to local rendering capabilities. + /// + public bool DarkMode { get; init; } +} + +/// +/// Represents semantic sidebar behavior options. +/// +/// +/// The enum is used by profiles and user preferences for consistent persistence. +/// +public enum SidebarMode +{ + Expanded, + Collapsed, + Overlay +} + +/// +/// Represents semantic navbar styles. +/// +/// +/// Values should map through design-system adapters and not be interpreted as CSS classes directly. +/// +public enum NavbarStyle +{ + Default, + Contrast, + Minimal, + Elevated +} diff --git a/src/Code311.Ui.Abstractions/Widgets/WidgetContracts.cs b/src/Code311.Ui.Abstractions/Widgets/WidgetContracts.cs new file mode 100644 index 0000000..50eb0c8 --- /dev/null +++ b/src/Code311.Ui.Abstractions/Widgets/WidgetContracts.cs @@ -0,0 +1,136 @@ +namespace Code311.Ui.Abstractions.Widgets; + +/// +/// Defines a widget metadata contract used by widget packages. +/// +/// +/// Implementations describe widget identity, category, and option type information for discovery and rendering pipelines. +/// +public interface IWidgetDefinition +{ + /// + /// Gets the stable widget key. + /// + /// + /// Keys should be unique across all registered widgets in a host. + /// + string Key { get; } + + /// + /// Gets the display name. + /// + /// + /// Display names are intended for catalogs and dashboard editors. + /// + string DisplayName { get; } + + /// + /// Gets the widget category. + /// + /// + /// Categories help hosts group widgets in catalogs. + /// + string Category { get; } +} + +/// +/// Provides widget initialization behavior. +/// +/// +/// Initialization should remain side-effect-aware and be idempotent where feasible. +/// +public interface IWidgetInitializer +{ + /// + /// Initializes a widget instance. + /// + /// The widget initialization context. + /// The cancellation token. + /// A task that completes when initialization has finished. + /// + /// Implementations should avoid performing long blocking operations. + /// + Task InitializeAsync(WidgetInitializationContext context, CancellationToken cancellationToken = default); +} + +/// +/// Contributes assets required by a widget. +/// +/// +/// Asset contributions are consumed by host adapters to build deterministic manifests. +/// +public interface IWidgetAssetContributor +{ + /// + /// Gets asset contributions for a widget. + /// + /// The asset contribution context. + /// A sequence of asset descriptors. + /// + /// Returned assets should be stable and avoid per-request randomization. + /// + IReadOnlyCollection Contribute(WidgetAssetContext context); +} + +/// +/// Serializes widget options for transport and client bootstrapping. +/// +/// +/// Serializers should be deterministic to support caching and diagnostics. +/// +public interface IWidgetSerializer +{ + /// + /// Serializes options to a string payload. + /// + /// The options object. + /// A serialized payload. + /// + /// Payload format is implementation-defined but should be documented per widget package. + /// + string Serialize(object options); +} + +/// +/// Represents a widget initialization request context. +/// +/// The widget key. +/// The tenant identifier. +/// The user identifier. +/// +/// The context includes tenant and user identity for multi-tenant-safe initialization logic. +/// +public sealed record WidgetInitializationContext(string WidgetKey, string TenantId, string UserId); + +/// +/// Represents a widget asset contribution context. +/// +/// The widget key. +/// +/// The context may be expanded later with environment information while keeping backward compatibility. +/// +public sealed record WidgetAssetContext(string WidgetKey); + +/// +/// Describes a widget asset reference. +/// +/// The asset path. +/// The asset type. +/// The ordering index. +/// +/// Ordering values enable deterministic include order across contributors. +/// +public sealed record WidgetAssetDescriptor(string Path, WidgetAssetType Type, int Order = 0); + +/// +/// Identifies supported widget asset types. +/// +/// +/// Types are interpreted by host integrations when building manifests. +/// +public enum WidgetAssetType +{ + Script, + Style, + Module +} diff --git a/src/Code311.Ui.Core/.gitkeep b/src/Code311.Ui.Core/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Code311.Ui.Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Code311.Ui.Core/Code311.Ui.Core.csproj b/src/Code311.Ui.Core/Code311.Ui.Core.csproj new file mode 100644 index 0000000..340ed17 --- /dev/null +++ b/src/Code311.Ui.Core/Code311.Ui.Core.csproj @@ -0,0 +1,15 @@ + + + net10.0 + Code311.Ui.Core + Code311.Ui.Core + Framework-neutral default services for Code311 UI orchestration. + Code311.Ui.Core + + + + + + + + diff --git a/src/Code311.Ui.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Code311.Ui.Core/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..32adfda --- /dev/null +++ b/src/Code311.Ui.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Code311.Ui.Abstractions.Options; +using Code311.Ui.Abstractions.Theming; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Code311.Ui.Core.Theming; +using Microsoft.Extensions.DependencyInjection; + +namespace Code311.Ui.Core.DependencyInjection; + +/// +/// Provides dependency-injection registrations for framework-neutral Code311 core services. +/// +/// +/// Registrations in this extension method avoid design-system concerns and only wire neutral orchestration services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers Code311 neutral core services. + /// + /// The DI service collection. + /// An optional options configuration callback. + /// The updated service collection. + /// + /// This method is intentionally minimal and can be extended in later phases without changing abstraction contracts. + /// + public static IServiceCollection AddCode311UiCore( + this IServiceCollection services, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + + services.AddOptions(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Code311.Ui.Core/Feedback/FeedbackChannel.cs b/src/Code311.Ui.Core/Feedback/FeedbackChannel.cs new file mode 100644 index 0000000..d52fa30 --- /dev/null +++ b/src/Code311.Ui.Core/Feedback/FeedbackChannel.cs @@ -0,0 +1,73 @@ +using Code311.Ui.Abstractions.Semantics; +using System.Collections.Concurrent; + +namespace Code311.Ui.Core.Feedback; + +/// +/// Represents a semantic feedback message produced by neutral orchestration services. +/// +/// The semantic tone. +/// The message text. +/// An optional code for diagnostics and localization. +/// The UTC creation timestamp. +/// +/// Feedback messages intentionally avoid design-system rendering details. +/// +public sealed record FeedbackMessage(UiTone Tone, string Message, string? Code, DateTimeOffset CreatedAt); + +/// +/// Defines a feedback pipeline channel for transient messages. +/// +/// +/// The channel is suitable for in-process orchestration and can be adapted to host-specific transport models. +/// +public interface IFeedbackChannel +{ + /// + /// Publishes a feedback message. + /// + /// The message to publish. + /// + /// Implementations should preserve publish order. + /// + void Publish(FeedbackMessage message); + + /// + /// Drains all currently queued messages. + /// + /// The drained messages in publish order. + /// + /// Draining clears the channel queue for the current in-memory instance. + /// + IReadOnlyList Drain(); +} + +/// +/// Default in-memory feedback channel implementation. +/// +/// +/// This channel is thread-safe and deterministic for typical request-scope and app-scope usage. +/// +public sealed class InMemoryFeedbackChannel : IFeedbackChannel +{ + private readonly ConcurrentQueue _queue = new(); + + /// + public void Publish(FeedbackMessage message) + { + ArgumentNullException.ThrowIfNull(message); + _queue.Enqueue(message); + } + + /// + public IReadOnlyList Drain() + { + var drained = new List(); + while (_queue.TryDequeue(out var message)) + { + drained.Add(message); + } + + return drained; + } +} diff --git a/src/Code311.Ui.Core/Loading/LoadingOrchestration.cs b/src/Code311.Ui.Core/Loading/LoadingOrchestration.cs new file mode 100644 index 0000000..560ae93 --- /dev/null +++ b/src/Code311.Ui.Core/Loading/LoadingOrchestration.cs @@ -0,0 +1,161 @@ +using System.Collections.Concurrent; + +namespace Code311.Ui.Core.Loading; + +/// +/// Coordinates semantic busy-state scopes. +/// +/// +/// Busy coordination is intentionally rendering-agnostic and can be consumed by any host adapter. +/// +public interface IBusyStateCoordinator +{ + /// + /// Enters a busy scope. + /// + /// The logical scope key. + /// An that exits the scope when disposed. + /// + /// Nested scopes increment counters and require matching disposal to clear busy state. + /// + IDisposable EnterScope(string scope); + + /// + /// Gets a value indicating whether the specified scope is currently busy. + /// + /// The logical scope key. + /// when busy; otherwise . + /// + /// Scope checks are safe for concurrent access. + /// + bool IsBusy(string scope); +} + +/// +/// Coordinates semantic preloader state. +/// +/// +/// Preloader orchestration allows hosts to trigger and clear loader states without UI-framework coupling. +/// +public interface IPreloaderOrchestrator +{ + /// + /// Marks a preloader key as active. + /// + /// The preloader key. + /// + /// Repeated calls are idempotent for the same key. + /// + void Start(string key = "default"); + + /// + /// Marks a preloader key as inactive. + /// + /// The preloader key. + /// + /// Completing a missing key is safe and has no effect. + /// + void Complete(string key = "default"); + + /// + /// Gets a value indicating whether a key is currently active. + /// + /// The preloader key. + /// when active; otherwise . + /// + /// Key checks are case-insensitive. + /// + bool IsActive(string key = "default"); +} + +/// +/// Default in-memory busy-state coordinator. +/// +/// +/// Counters are tracked per scope key and support nested scope usage. +/// +public sealed class BusyStateCoordinator : IBusyStateCoordinator +{ + private readonly ConcurrentDictionary _counters = new(StringComparer.OrdinalIgnoreCase); + + /// + public IDisposable EnterScope(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + _counters.AddOrUpdate(scope, 1, static (_, current) => current + 1); + return new Scope(this, scope); + } + + /// + public bool IsBusy(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + return _counters.TryGetValue(scope, out var value) && value > 0; + } + + private void ExitScope(string scope) + { + _counters.AddOrUpdate(scope, 0, static (_, current) => Math.Max(0, current - 1)); + + if (_counters.TryGetValue(scope, out var value) && value == 0) + { + _counters.TryRemove(scope, out _); + } + } + + private sealed class Scope : IDisposable + { + private readonly BusyStateCoordinator _owner; + private readonly string _scope; + private bool _disposed; + + public Scope(BusyStateCoordinator owner, string scope) + { + _owner = owner; + _scope = scope; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _owner.ExitScope(_scope); + _disposed = true; + } + } +} + +/// +/// Default in-memory preloader orchestrator. +/// +/// +/// Active keys are tracked as a set for predictable activation checks. +/// +public sealed class PreloaderOrchestrator : IPreloaderOrchestrator +{ + private readonly ConcurrentDictionary _activeKeys = new(StringComparer.OrdinalIgnoreCase); + + /// + public void Start(string key = "default") + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + _activeKeys[key] = 0; + } + + /// + public void Complete(string key = "default") + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + _activeKeys.TryRemove(key, out _); + } + + /// + public bool IsActive(string key = "default") + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return _activeKeys.ContainsKey(key); + } +} diff --git a/src/Code311.Ui.Core/Theming/ThemeRegistry.cs b/src/Code311.Ui.Core/Theming/ThemeRegistry.cs new file mode 100644 index 0000000..a53a896 --- /dev/null +++ b/src/Code311.Ui.Core/Theming/ThemeRegistry.cs @@ -0,0 +1,134 @@ +using Code311.Ui.Abstractions.Options; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Microsoft.Extensions.Options; + +namespace Code311.Ui.Core.Theming; + +/// +/// Provides runtime registration and retrieval for semantic theme profiles. +/// +/// +/// Theme registry operations are in-memory and deterministic, and can be composed with persistence in later phases. +/// +public interface IThemeRegistry +{ + /// + /// Registers or replaces a theme profile. + /// + /// The theme profile. + /// + /// Profiles are keyed by name using case-insensitive lookup semantics. + /// + void Register(ThemeProfile profile); + + /// + /// Gets a theme profile by name. + /// + /// The profile name. + /// The profile when found; otherwise . + /// + /// Callers can use this method for fast non-async resolution where appropriate. + /// + ThemeProfile? Get(string name); + + /// + /// Returns all currently registered profiles. + /// + /// A read-only collection of profiles. + /// + /// The returned collection is a snapshot and is safe for enumeration. + /// + IReadOnlyCollection GetAll(); +} + +/// +/// Default in-memory implementation of . +/// +/// +/// This implementation avoids design-system details and only stores semantic profile values. +/// +public sealed class ThemeRegistry : IThemeRegistry +{ + private readonly Dictionary _profiles = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// + /// The registry starts empty and can be seeded by startup modules. + /// + public ThemeRegistry() + { + } + + /// + public void Register(ThemeProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + _profiles[profile.Name] = profile; + } + + /// + public ThemeProfile? Get(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _profiles.TryGetValue(name, out var profile) ? profile : null; + } + + /// + public IReadOnlyCollection GetAll() => _profiles.Values.ToArray(); +} + +/// +/// Default theme resolver implementation based on registered themes and framework options. +/// +/// +/// Resolver behavior is framework-neutral and does not expose any Tabler-specific semantics. +/// +public sealed class DefaultThemeProfileResolver : IThemeProfileResolver +{ + private const string DefaultThemeName = "default"; + private readonly IThemeRegistry _themeRegistry; + private readonly UiFrameworkOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The theme registry dependency. + /// The options accessor. + /// + /// The resolver uses options values to compose a deterministic fallback profile. + /// + public DefaultThemeProfileResolver(IThemeRegistry themeRegistry, IOptions options) + { + _themeRegistry = themeRegistry ?? throw new ArgumentNullException(nameof(themeRegistry)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public Task ResolveAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + cancellationToken.ThrowIfCancellationRequested(); + + var explicitTheme = _themeRegistry.Get(name); + if (explicitTheme is not null) + { + return Task.FromResult(explicitTheme); + } + + var fallbackTheme = _themeRegistry.Get(DefaultThemeName) ?? CreateFallback(); + return Task.FromResult(fallbackTheme); + } + + private ThemeProfile CreateFallback() => new() + { + Name = DefaultThemeName, + Density = _options.DefaultDensity, + Tone = UiTone.Neutral, + SidebarMode = SidebarMode.Expanded, + NavbarStyle = NavbarStyle.Default, + DarkMode = false + }; +} diff --git a/tests/Code311.Tests.Host/Code311.Tests.Host.csproj b/tests/Code311.Tests.Host/Code311.Tests.Host.csproj new file mode 100644 index 0000000..777d17e --- /dev/null +++ b/tests/Code311.Tests.Host/Code311.Tests.Host.csproj @@ -0,0 +1,18 @@ + + + net10.0 + true + + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Host/PreferenceOrchestratorTests.cs b/tests/Code311.Tests.Host/PreferenceOrchestratorTests.cs new file mode 100644 index 0000000..23daccf --- /dev/null +++ b/tests/Code311.Tests.Host/PreferenceOrchestratorTests.cs @@ -0,0 +1,50 @@ +using Code311.Host.Services; +using Code311.Ui.Abstractions.Preferences; +using Code311.Ui.Abstractions.Semantics; +using Xunit; + +namespace Code311.Tests.Host; + +public sealed class PreferenceOrchestratorTests +{ + [Fact] + public async Task GetCurrentAsync_ShouldReturnDefaults_WhenStoreMissing() + { + var store = new InMemoryPreferenceStore(); + var orchestrator = new PreferenceOrchestrator(store, new DemoUserContext()); + + var current = await orchestrator.GetCurrentAsync(); + + Assert.Equal("demo-tenant", current.TenantId); + Assert.Equal("demo-user", current.UserId); + Assert.Equal("default", current.Theme); + } + + [Fact] + public async Task SaveAsync_ShouldPersistForDemoScope() + { + var store = new InMemoryPreferenceStore(); + var orchestrator = new PreferenceOrchestrator(store, new DemoUserContext()); + + await orchestrator.SaveAsync(new PreferenceInputModel("blue", UiDensity.Compact, 40, true)); + var loaded = await orchestrator.GetCurrentAsync(); + + Assert.Equal("blue", loaded.Theme); + Assert.Equal(40, loaded.DefaultPageSize); + Assert.True(loaded.SidebarCollapsed); + } + + private sealed class InMemoryPreferenceStore : IUserUiPreferenceStore + { + private UserUiPreference? _current; + + public Task GetAsync(string tenantId, string userId, CancellationToken cancellationToken = default) + => Task.FromResult(_current is not null && _current.TenantId == tenantId && _current.UserId == userId ? _current : null); + + public Task UpsertAsync(UserUiPreference preference, CancellationToken cancellationToken = default) + { + _current = preference with { UpdatedAt = DateTimeOffset.UtcNow }; + return Task.CompletedTask; + } + } +} diff --git a/tests/Code311.Tests.Integration.Mvc/.gitkeep b/tests/Code311.Tests.Integration.Mvc/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Integration.Mvc/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Integration.Mvc/Code311.Tests.Integration.Mvc.csproj b/tests/Code311.Tests.Integration.Mvc/Code311.Tests.Integration.Mvc.csproj new file mode 100644 index 0000000..34ef52d --- /dev/null +++ b/tests/Code311.Tests.Integration.Mvc/Code311.Tests.Integration.Mvc.csproj @@ -0,0 +1,16 @@ + + + net10.0 + true + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Integration.Mvc/MvcIntegrationTests.cs b/tests/Code311.Tests.Integration.Mvc/MvcIntegrationTests.cs new file mode 100644 index 0000000..570abd1 --- /dev/null +++ b/tests/Code311.Tests.Integration.Mvc/MvcIntegrationTests.cs @@ -0,0 +1,112 @@ +using Code311.Tabler.Mvc.Assets; +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Widgets; +using Code311.Tabler.Mvc.Feedback; +using Code311.Tabler.Mvc.Filters; +using Code311.Tabler.Mvc.Theming; +using Code311.Ui.Abstractions.Options; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Code311.Ui.Core.Theming; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Code311.Tests.Integration.Mvc; + +/// +/// Validates MVC adapter request lifecycle integration behaviors. +/// +public sealed class MvcIntegrationTests +{ + [Fact] + public void FeedbackActionFilter_ShouldPublishModelStateErrors() + { + var channel = new InMemoryFeedbackChannel(); + var scoped = new Code311RequestFeedbackStore(); + var filter = new Code311FeedbackActionFilter(channel, scoped); + + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + actionContext.ModelState.AddModelError("Name", "Name is required."); + + var ctx = new ActionExecutingContext(actionContext, [], new Dictionary(), new object()); + filter.OnActionExecuting(ctx); + + Assert.Single(scoped.GetAll()); + Assert.Single(channel.Drain()); + } + + [Fact] + public void BusyTransitionFilter_ShouldToggleBusyAndPreloader() + { + var busy = new BusyStateCoordinator(); + var preloader = new PreloaderOrchestrator(); + var filter = new Code311BusyTransitionFilter(busy, preloader); + + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var executing = new ActionExecutingContext(actionContext, [], new Dictionary(), new object()); + var executed = new ActionExecutedContext(actionContext, [], new object()); + + filter.OnActionExecuting(executing); + Assert.True(busy.IsBusy("mvc-request")); + Assert.True(preloader.IsActive("mvc-request")); + + filter.OnActionExecuted(executed); + Assert.False(busy.IsBusy("mvc-request")); + Assert.False(preloader.IsActive("mvc-request")); + } + + [Fact] + public async Task ThemeFilter_ShouldSetRequestThemeContext() + { + var registry = new ThemeRegistry(); + registry.Register(new Code311.Ui.Abstractions.Theming.ThemeProfile { Name = "blue" }); + var resolver = new DefaultThemeProfileResolver(registry, Options.Create(new UiFrameworkOptions())); + var themeContext = new Code311ThemeRequestContext(); + var filter = new Code311ThemeContextFilter(resolver, themeContext); + + var http = new DefaultHttpContext(); + http.Request.QueryString = new QueryString("?theme=blue"); + var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var executing = new ActionExecutingContext(actionContext, [], new Dictionary(), new object()); + + await filter.OnActionExecutionAsync(executing, () => Task.FromResult(new ActionExecutedContext(actionContext, [], new object()))); + + Assert.Equal("blue", themeContext.Current?.Name); + } + + [Fact] + public void AssetRequestStore_ShouldAcceptWidgetAssetContributions() + { + var store = new Code311AssetRequestStore(); + var widget = new TestWidgetSlotParticipant(); + + store.AddWidgetAssets(widget); + + var assets = store.GetAll(); + Assert.Collection( + assets, + asset => Assert.False(asset.IsScript), + asset => Assert.True(asset.IsScript)); + } + + private sealed class TestWidgetSlotParticipant : ITablerWidgetSlotParticipant + { + public TablerWidgetSlotDefinition Slot { get; } = new("test", "panel-body", new TablerWidgetOptionsEnvelope(new Dictionary())); + + public IReadOnlyList GetAssetContributions() + => + [ + new("/scripts/test.js", TablerAssetType.Script, 20), + new("/styles/test.css", TablerAssetType.Style, 10) + ]; + + public TablerWidgetInitializationRequest CreateInitialization(string elementId) + => new(Slot.WidgetKey, elementId, "{}"); + } +} diff --git a/tests/Code311.Tests.Integration.Razor/.gitkeep b/tests/Code311.Tests.Integration.Razor/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Integration.Razor/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Integration.Razor/Code311.Tests.Integration.Razor.csproj b/tests/Code311.Tests.Integration.Razor/Code311.Tests.Integration.Razor.csproj new file mode 100644 index 0000000..5daa06d --- /dev/null +++ b/tests/Code311.Tests.Integration.Razor/Code311.Tests.Integration.Razor.csproj @@ -0,0 +1,16 @@ + + + net10.0 + true + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Integration.Razor/RazorIntegrationTests.cs b/tests/Code311.Tests.Integration.Razor/RazorIntegrationTests.cs new file mode 100644 index 0000000..5ee63dc --- /dev/null +++ b/tests/Code311.Tests.Integration.Razor/RazorIntegrationTests.cs @@ -0,0 +1,118 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Widgets; +using Code311.Tabler.Razor.Assets; +using Code311.Tabler.Razor.Feedback; +using Code311.Tabler.Razor.Filters; +using Code311.Tabler.Razor.Theming; +using Code311.Ui.Abstractions.Options; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Code311.Ui.Core.Theming; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Code311.Tests.Integration.Razor; + +/// +/// Validates Razor adapter request lifecycle integration behaviors. +/// +public sealed class RazorIntegrationTests +{ + [Fact] + public async Task PageFeedbackFilter_ShouldCaptureModelErrors() + { + var channel = new InMemoryFeedbackChannel(); + var scoped = new Code311RequestFeedbackStore(); + var filter = new Code311PageFeedbackFilter(channel, scoped); + + var actionContext = new Microsoft.AspNetCore.Mvc.ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + actionContext.ModelState.AddModelError("Email", "Email invalid"); + + var pageContext = new PageContext(actionContext); + var executing = new PageHandlerExecutingContext(pageContext, [], new HandlerMethodDescriptor(), new Dictionary(), new object()); + + await filter.OnPageHandlerExecutionAsync(executing, () => Task.FromResult(new PageHandlerExecutedContext(pageContext, [], new HandlerMethodDescriptor(), new object()))); + + Assert.Single(scoped.GetAll()); + Assert.Single(channel.Drain()); + } + + [Fact] + public async Task BusyTransitionPageFilter_ShouldToggleRequestScope() + { + var busy = new BusyStateCoordinator(); + var preloader = new PreloaderOrchestrator(); + var filter = new Code311BusyTransitionPageFilter(busy, preloader); + + var actionContext = new Microsoft.AspNetCore.Mvc.ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var pageContext = new PageContext(actionContext); + var executing = new PageHandlerExecutingContext(pageContext, [], new HandlerMethodDescriptor(), new Dictionary(), new object()); + + await filter.OnPageHandlerExecutionAsync(executing, () => + { + Assert.True(busy.IsBusy("razor-request")); + Assert.True(preloader.IsActive("razor-request")); + return Task.FromResult(new PageHandlerExecutedContext(pageContext, [], new HandlerMethodDescriptor(), new object())); + }); + + Assert.False(busy.IsBusy("razor-request")); + Assert.False(preloader.IsActive("razor-request")); + } + + [Fact] + public async Task ThemePageFilter_ShouldSetThemeContext() + { + var registry = new ThemeRegistry(); + registry.Register(new Code311.Ui.Abstractions.Theming.ThemeProfile { Name = "green" }); + var resolver = new DefaultThemeProfileResolver(registry, Options.Create(new UiFrameworkOptions())); + var themeContext = new Code311ThemeRequestContext(); + var filter = new Code311ThemePageFilter(resolver, themeContext); + + var http = new DefaultHttpContext(); + http.Request.QueryString = new QueryString("?theme=green"); + var actionContext = new Microsoft.AspNetCore.Mvc.ActionContext(http, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var pageContext = new PageContext(actionContext); + var executing = new PageHandlerExecutingContext(pageContext, [], new HandlerMethodDescriptor(), new Dictionary(), new object()); + + await filter.OnPageHandlerExecutionAsync(executing, () => Task.FromResult(new PageHandlerExecutedContext(pageContext, [], new HandlerMethodDescriptor(), new object()))); + + Assert.Equal("green", themeContext.Current?.Name); + } + + [Fact] + public void AssetRequestStore_ShouldAcceptWidgetAssetContributions() + { + var store = new Code311AssetRequestStore(); + var widget = new TestWidgetSlotParticipant(); + + store.AddWidgetAssets(widget); + + var assets = store.GetAll(); + Assert.Collection( + assets, + asset => Assert.False(asset.IsScript), + asset => Assert.True(asset.IsScript)); + } + + private sealed class TestWidgetSlotParticipant : ITablerWidgetSlotParticipant + { + public TablerWidgetSlotDefinition Slot { get; } = new("test", "panel-body", new TablerWidgetOptionsEnvelope(new Dictionary())); + + public IReadOnlyList GetAssetContributions() + => + [ + new("/scripts/test.js", TablerAssetType.Script, 20), + new("/styles/test.css", TablerAssetType.Style, 10) + ]; + + public TablerWidgetInitializationRequest CreateInitialization(string elementId) + => new(Slot.WidgetKey, elementId, "{}"); + } +} diff --git a/tests/Code311.Tests.Licensing/.gitkeep b/tests/Code311.Tests.Licensing/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Licensing/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Licensing/Code311.Tests.Licensing.csproj b/tests/Code311.Tests.Licensing/Code311.Tests.Licensing.csproj new file mode 100644 index 0000000..eada64b --- /dev/null +++ b/tests/Code311.Tests.Licensing/Code311.Tests.Licensing.csproj @@ -0,0 +1,18 @@ + + + net10.0 + true + + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Licensing/LicensingServicesTests.cs b/tests/Code311.Tests.Licensing/LicensingServicesTests.cs new file mode 100644 index 0000000..bf2d45f --- /dev/null +++ b/tests/Code311.Tests.Licensing/LicensingServicesTests.cs @@ -0,0 +1,146 @@ +using Code311.Licensing.DependencyInjection; +using Code311.Licensing.Diagnostics; +using Code311.Licensing.Models; +using Code311.Licensing.Validation; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Code311.Tests.Licensing; + +public sealed class LicensingServicesTests +{ + [Fact] + public async Task Validator_ShouldReturnValid_ForActiveLicense() + { + var validator = new DefaultLicenseValidator(); + var license = BuildLicense(expiresUtc: DateTimeOffset.UtcNow.AddDays(30)); + + var result = validator.Validate(license, DateTimeOffset.UtcNow, new LicensingOptions()); + + Assert.True(result.IsValid); + Assert.Equal(LicenseStatusLevel.Valid, result.OverallLevel); + } + + [Fact] + public void Validator_ShouldReturnInvalid_ForExpiredLicense() + { + var validator = new DefaultLicenseValidator(); + var license = BuildLicense(expiresUtc: DateTimeOffset.UtcNow.AddMinutes(-1)); + + var result = validator.Validate(license, DateTimeOffset.UtcNow, new LicensingOptions()); + + Assert.False(result.IsValid); + Assert.Equal(LicenseStatusLevel.Error, result.OverallLevel); + } + + [Fact] + public async Task StartupValidator_ShouldThrow_WhenStartupRequiresValidLicense() + { + var services = new ServiceCollection() + .AddCode311Licensing(options => options.RequireValidLicenseAtStartup = true) + .AddCode311InMemoryLicenseSource(null) + .BuildServiceProvider(); + + var startup = services.GetRequiredService(); + + await Assert.ThrowsAsync(() => startup.ValidateAtStartupAsync()); + } + + [Fact] + public async Task StartupValidator_ShouldReportStatus_WhenValidationRuns() + { + var services = new ServiceCollection() + .AddCode311Licensing(options => options.RequireValidLicenseAtStartup = false) + .AddCode311InMemoryLicenseSource(BuildLicense(expiresUtc: DateTimeOffset.UtcNow.AddDays(20))) + .BuildServiceProvider(); + + var startup = services.GetRequiredService(); + var reporter = services.GetRequiredService(); + + var result = await startup.ValidateAtStartupAsync(); + + Assert.True(result.IsValid); + Assert.NotNull(reporter.Current); + Assert.Equal(LicenseCheckStage.Startup, reporter.Current!.Stage); + } + + [Fact] + public async Task FeatureGate_ShouldDeny_WhenFeatureMissing() + { + var services = new ServiceCollection() + .AddCode311Licensing(options => options.RequireValidLicenseAtStartup = false) + .AddCode311InMemoryLicenseSource(BuildLicense(features: new HashSet(StringComparer.OrdinalIgnoreCase) { "dashboard.basic" })) + .BuildServiceProvider(); + + var gate = services.GetRequiredService(); + + var result = await gate.CheckFeatureAsync("dashboard.advanced"); + + Assert.False(result.IsAllowed); + Assert.Equal(LicenseStatusLevel.Warning, result.Level); + } + + [Fact] + public async Task FeatureGate_ShouldAllow_WhenFeaturePresent() + { + var services = new ServiceCollection() + .AddCode311Licensing(options => options.RequireValidLicenseAtStartup = false) + .AddCode311InMemoryLicenseSource(BuildLicense(features: new HashSet(StringComparer.OrdinalIgnoreCase) { "dashboard.advanced" })) + .BuildServiceProvider(); + + var gate = services.GetRequiredService(); + + var result = await gate.CheckFeatureAsync("dashboard.advanced"); + + Assert.True(result.IsAllowed); + Assert.Equal("dashboard.advanced", result.Feature); + } + + [Fact] + public void AddCode311Licensing_ShouldRegisterCoreServices() + { + var services = new ServiceCollection(); + services.AddCode311Licensing(); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void UiAndTablerPackages_ShouldNotReferenceCode311Licensing() + { + var forbiddenProjectFiles = new[] + { + "src/Code311.Ui.Abstractions/Code311.Ui.Abstractions.csproj", + "src/Code311.Ui.Core/Code311.Ui.Core.csproj", + "src/Code311.Tabler.Core/Code311.Tabler.Core.csproj", + "src/Code311.Tabler.Components/Code311.Tabler.Components.csproj", + "src/Code311.Tabler.Dashboard/Code311.Tabler.Dashboard.csproj", + "src/Code311.Tabler.Widgets.DataTables/Code311.Tabler.Widgets.DataTables.csproj", + "src/Code311.Tabler.Widgets.Calendar/Code311.Tabler.Widgets.Calendar.csproj", + "src/Code311.Tabler.Widgets.Charts/Code311.Tabler.Widgets.Charts.csproj" + }; + + foreach (var relativePath in forbiddenProjectFiles) + { + var full = Path.Combine(AppContext.BaseDirectory, "../../../../", relativePath); + var xml = File.ReadAllText(full); + Assert.DoesNotContain("Code311.Licensing", xml, StringComparison.Ordinal); + } + } + + private static Code311License BuildLicense(DateTimeOffset? expiresUtc = null, DateTimeOffset? notBeforeUtc = null, IReadOnlySet? features = null) + => new() + { + LicenseId = "lic-001", + CustomerName = "Code311 Test", + Plan = "pro", + NotBeforeUtc = notBeforeUtc, + ExpiresUtc = expiresUtc ?? DateTimeOffset.UtcNow.AddDays(30), + Features = features ?? new HashSet(StringComparer.OrdinalIgnoreCase) { "dashboard.basic" } + }; +} diff --git a/tests/Code311.Tests.Persistence.EFCore/.gitkeep b/tests/Code311.Tests.Persistence.EFCore/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Persistence.EFCore/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Persistence.EFCore/Code311.Tests.Persistence.EFCore.csproj b/tests/Code311.Tests.Persistence.EFCore/Code311.Tests.Persistence.EFCore.csproj new file mode 100644 index 0000000..b4331f2 --- /dev/null +++ b/tests/Code311.Tests.Persistence.EFCore/Code311.Tests.Persistence.EFCore.csproj @@ -0,0 +1,20 @@ + + + net10.0 + true + + + + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Persistence.EFCore/EfCoreUserUiPreferenceStoreTests.cs b/tests/Code311.Tests.Persistence.EFCore/EfCoreUserUiPreferenceStoreTests.cs new file mode 100644 index 0000000..238ab32 --- /dev/null +++ b/tests/Code311.Tests.Persistence.EFCore/EfCoreUserUiPreferenceStoreTests.cs @@ -0,0 +1,98 @@ +using Code311.Persistence.EFCore.DependencyInjection; +using Code311.Ui.Abstractions.Preferences; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Code311.Tests.Persistence.EFCore; + +public sealed class EfCoreUserUiPreferenceStoreTests +{ + [Fact] + public async Task UpsertAndGet_ShouldRoundTripPreference() + { + await using var provider = BuildServiceProvider(); + using var scope = provider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + + var preference = CreatePreference("tenant-a", "user-1", "dark"); + await store.UpsertAsync(preference); + + var loaded = await store.GetAsync("tenant-a", "user-1"); + + Assert.NotNull(loaded); + Assert.Equal("dark", loaded!.Theme); + Assert.Equal(UiDensity.Compact, loaded.Density); + } + + [Fact] + public async Task Get_ShouldRespectTenantAndUserScope() + { + await using var provider = BuildServiceProvider(); + using var scope = provider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.UpsertAsync(CreatePreference("tenant-a", "user-1", "dark")); + await store.UpsertAsync(CreatePreference("tenant-b", "user-1", "light")); + + var tenantA = await store.GetAsync("tenant-a", "user-1"); + var tenantB = await store.GetAsync("tenant-b", "user-1"); + var missing = await store.GetAsync("tenant-c", "user-1"); + + Assert.Equal("dark", tenantA!.Theme); + Assert.Equal("light", tenantB!.Theme); + Assert.Null(missing); + } + + [Fact] + public async Task Upsert_ShouldUpdateExistingRowForSameTenantAndUser() + { + await using var provider = BuildServiceProvider(); + using var scope = provider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + + await store.UpsertAsync(CreatePreference("tenant-a", "user-1", "dark")); + await Task.Delay(5); + await store.UpsertAsync(CreatePreference("tenant-a", "user-1", "light") with { SidebarCollapsed = false }); + + var loaded = await store.GetAsync("tenant-a", "user-1"); + + Assert.NotNull(loaded); + Assert.Equal("light", loaded!.Theme); + Assert.False(loaded.SidebarCollapsed); + } + + [Fact] + public void AddCode311PersistenceEfCore_ShouldRegisterStore() + { + var services = new ServiceCollection(); + + services.AddCode311PersistenceEfCore(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + Assert.NotNull(scope.ServiceProvider.GetService()); + } + + private static ServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + services.AddCode311PersistenceEfCore(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))); + return services.BuildServiceProvider(); + } + + private static UserUiPreference CreatePreference(string tenantId, string userId, string theme) + => new() + { + TenantId = tenantId, + UserId = userId, + Theme = theme, + Density = UiDensity.Compact, + SidebarCollapsed = true, + DefaultPageSize = 50, + Language = "en-US", + TimeZone = "UTC" + }; +} diff --git a/tests/Code311.Tests.Persistence.EFCore/ModelBuilderExtensionsTests.cs b/tests/Code311.Tests.Persistence.EFCore/ModelBuilderExtensionsTests.cs new file mode 100644 index 0000000..9bc9e99 --- /dev/null +++ b/tests/Code311.Tests.Persistence.EFCore/ModelBuilderExtensionsTests.cs @@ -0,0 +1,32 @@ +using Code311.Persistence.EFCore.Entities; +using Code311.Persistence.EFCore.Extensions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Code311.Tests.Persistence.EFCore; + +public sealed class ModelBuilderExtensionsTests +{ + [Fact] + public void ApplyCode311PreferenceStorage_ShouldConfigureCompositeKey() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + + using var context = new TestPreferenceDbContext(options); + var entityType = context.Model.FindEntityType(typeof(UserUiPreferenceEntity)); + + Assert.NotNull(entityType); + Assert.Equal(new[] { "TenantId", "UserId" }, entityType!.FindPrimaryKey()!.Properties.Select(x => x.Name)); + } + + private sealed class TestPreferenceDbContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyCode311PreferenceStorage(); + base.OnModelCreating(modelBuilder); + } + } +} diff --git a/tests/Code311.Tests.Tabler.Components/.gitkeep b/tests/Code311.Tests.Tabler.Components/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Components/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Tabler.Components/Code311.Tests.Tabler.Components.csproj b/tests/Code311.Tests.Tabler.Components/Code311.Tests.Tabler.Components.csproj new file mode 100644 index 0000000..8116cab --- /dev/null +++ b/tests/Code311.Tests.Tabler.Components/Code311.Tests.Tabler.Components.csproj @@ -0,0 +1,19 @@ + + + net10.0 + true + + + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Components/ComponentRenderingTests.cs b/tests/Code311.Tests.Tabler.Components/ComponentRenderingTests.cs new file mode 100644 index 0000000..8a2fda1 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Components/ComponentRenderingTests.cs @@ -0,0 +1,140 @@ +using System.IO; +using Code311.Tabler.Components.Common; +using Code311.Tabler.Components.Data; +using Code311.Tabler.Components.Feedback; +using Code311.Tabler.Components.Forms; +using Code311.Tabler.Components.Layout; +using Code311.Tabler.Components.Media; +using Code311.Tabler.Components.Navigation; +using Code311.Tabler.Core.Mapping; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Xunit; + +namespace Code311.Tests.Tabler.Components; + +/// +/// Validates representative TagHelper and component behavior for the core component subset. +/// +/// +/// These tests intentionally focus on semantic-to-render mapping assertions rather than exhaustive HTML snapshots. +/// +public sealed class ComponentRenderingTests +{ + [Fact] + public void Cd311Input_ShouldRenderSemanticClasses() + { + var helper = new Cd311InputTagHelper(new TablerSemanticClassMapper()) + { + Field = "Email", + Label = "Email", + State = UiState.Active, + Size = UiSize.Large, + Appearance = UiAppearance.Outline + }; + + var output = CreateOutput(); + helper.Process(CreateContext(), output); + + var cls = output.Attributes["class"].Value?.ToString() ?? string.Empty; + Assert.Contains("form-control", cls); + Assert.Contains("state-active", cls); + } + + [Fact] + public void Cd311Tabs_ShouldRenderItems() + { + var helper = new Cd311TabsTagHelper(new TablerSemanticClassMapper()) + { + Items = + [ + new Cd311NavItem("Overview", "/", true), + new Cd311NavItem("Settings", "/settings") + ] + }; + + var output = CreateOutput(); + helper.Process(CreateContext(), output); + + var content = output.Content.GetContent(); + Assert.Contains("Overview", content); + Assert.Contains("Settings", content); + } + + [Fact] + public void Cd311Card_ShouldSetToneClass() + { + var helper = new Cd311CardTagHelper(new TablerSemanticClassMapper()) { Tone = UiTone.Warning, Title = "Card" }; + var output = CreateOutput(); + + helper.Process(CreateContext(), output); + + Assert.Contains("border-warning", output.Attributes["class"].Value?.ToString()); + } + + [Fact] + public void Cd311Progress_ShouldClampAndRenderBar() + { + var helper = new Cd311ProgressTagHelper(new TablerSemanticClassMapper()) { Value = 150, Tone = UiTone.Success }; + var output = CreateOutput(); + + helper.Process(CreateContext(), output); + + var content = output.Content.GetContent(); + Assert.Contains("width:100%", content); + Assert.Contains("bg-success", content); + } + + [Fact] + public void Cd311Badge_ShouldRenderTone() + { + var helper = new Cd311BadgeTagHelper(new TablerSemanticClassMapper()) { Tone = UiTone.Danger, Text = "Blocked" }; + var output = CreateOutput(); + + helper.Process(CreateContext(), output); + + Assert.Contains("bg-danger", output.Attributes["class"].Value?.ToString()); + Assert.Equal("Blocked", output.Content.GetContent()); + } + + [Fact] + public void Cd311Avatar_ShouldFallbackToInitials() + { + var helper = new Cd311AvatarTagHelper(new TablerSemanticClassMapper()) { Name = "Code Three" }; + var output = CreateOutput(); + + helper.Process(CreateContext(), output); + + Assert.Contains("avatar", output.Attributes["class"].Value?.ToString()); + Assert.Equal("CO", output.Content.GetContent()); + } + + [Fact] + public void BusyAndAlertComponents_ShouldConsumeUiCoreServices() + { + var busy = new BusyStateCoordinator(); + using var _ = busy.EnterScope("submit"); + + var overlay = new Cd311BusyOverlayTagHelper(busy) { Scope = "submit" }; + var overlayOutput = CreateOutput(); + overlay.Process(CreateContext(), overlayOutput); + + var feedback = new InMemoryFeedbackChannel(); + feedback.Publish(new FeedbackMessage(UiTone.Info, "Saved", null, DateTimeOffset.UtcNow)); + var host = new Cd311PageAlertHostViewComponent(feedback); + + Assert.Contains("active", overlayOutput.Attributes["class"].Value?.ToString()); + var result = Assert.IsType(host.Invoke()); + using var writer = new StringWriter(); + result.Content.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); + Assert.Contains("Saved", writer.ToString()); + } + + private static TagHelperContext CreateContext() => + new(new TagHelperAttributeList(), new Dictionary(), Guid.NewGuid().ToString("N")); + + private static TagHelperOutput CreateOutput() => + new("div", new TagHelperAttributeList(), static (_, _) => Task.FromResult(new DefaultTagHelperContent())); +} diff --git a/tests/Code311.Tests.Tabler.Core/.gitkeep b/tests/Code311.Tests.Tabler.Core/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Tabler.Core/Code311.Tests.Tabler.Core.csproj b/tests/Code311.Tests.Tabler.Core/Code311.Tests.Tabler.Core.csproj new file mode 100644 index 0000000..92a02d8 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Core/Code311.Tests.Tabler.Core.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Core/TablerCoreTests.cs b/tests/Code311.Tests.Tabler.Core/TablerCoreTests.cs new file mode 100644 index 0000000..55943ad --- /dev/null +++ b/tests/Code311.Tests.Tabler.Core/TablerCoreTests.cs @@ -0,0 +1,78 @@ +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Core.Theming; +using Xunit; + +namespace Code311.Tests.Tabler.Core; + +/// +/// Validates Tabler core mapping and asset foundation behavior. +/// +/// +/// Tests verify deterministic translation from semantic contracts to Tabler-specific values. +/// +public sealed class TablerCoreTests +{ + /// + /// Ensures tone mapping translates known semantic values. + /// + /// + /// Mapping outputs are internal details but must remain stable across releases. + /// + [Fact] + public void SemanticMapper_ShouldMapTone() + { + var mapper = new TablerSemanticClassMapper(); + + Assert.Equal("success", mapper.MapTone(UiTone.Success)); + Assert.Equal("danger", mapper.MapTone(UiTone.Danger)); + } + + /// + /// Ensures theme mapper builds expected layout classes. + /// + /// + /// Theme mapping should consume semantic profile data without leaking upstream concerns. + /// + [Fact] + public void ThemeMapper_ShouldBuildTablerThemeMap() + { + var mapper = new TablerThemeMapper(new TablerSemanticClassMapper()); + + var map = mapper.Map(new ThemeProfile + { + Name = "default", + Tone = UiTone.Accent, + Density = UiDensity.Comfortable, + SidebarMode = SidebarMode.Collapsed, + NavbarStyle = NavbarStyle.Contrast, + DarkMode = true + }); + + Assert.Equal("default", map.ThemeName); + Assert.Contains("theme-dark", map.BodyClass); + Assert.Contains("bg-primary", map.NavbarClass); + Assert.Contains("sidebar-collapsed", map.SidebarClass); + } + + /// + /// Ensures asset manifest includes deterministic core entries. + /// + /// + /// Core assets must preserve deterministic ordering for predictable host integration. + /// + [Fact] + public void AssetManifestProvider_ShouldReturnCoreAssets() + { + ITablerAssetManifestProvider provider = new DefaultTablerAssetManifestProvider(); + + var assets = provider.GetAssets(); + + Assert.Equal(2, assets.Count); + Assert.Equal(TablerAssetType.Style, assets[0].Type); + Assert.Equal(TablerAssetType.Script, assets[1].Type); + Assert.True(assets[0].Order < assets[1].Order); + } +} diff --git a/tests/Code311.Tests.Tabler.Dashboard/.gitkeep b/tests/Code311.Tests.Tabler.Dashboard/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Dashboard/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Tabler.Dashboard/Code311.Tests.Tabler.Dashboard.csproj b/tests/Code311.Tests.Tabler.Dashboard/Code311.Tests.Tabler.Dashboard.csproj new file mode 100644 index 0000000..e760768 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Dashboard/Code311.Tests.Tabler.Dashboard.csproj @@ -0,0 +1,18 @@ + + + net10.0 + true + + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Dashboard/DashboardCompositionTests.cs b/tests/Code311.Tests.Tabler.Dashboard/DashboardCompositionTests.cs new file mode 100644 index 0000000..2aa24c7 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Dashboard/DashboardCompositionTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using Code311.Tabler.Core.Mapping; +using Code311.Tabler.Dashboard.Composition; +using Code311.Tabler.Dashboard.Kpi; +using Code311.Tabler.Dashboard.Layout; +using Code311.Tabler.Dashboard.Models; +using Code311.Tabler.Dashboard.Panels; +using Code311.Ui.Abstractions.Semantics; +using Microsoft.AspNetCore.Mvc.ViewComponents; +using Xunit; + +namespace Code311.Tests.Tabler.Dashboard; + +/// +/// Validates dashboard composition and panel rendering surfaces. +/// +public sealed class DashboardCompositionTests +{ + [Fact] + public void DashboardComposer_ShouldComposeZonesAndPanels() + { + IDashboardPageComposer composer = new DefaultDashboardPageComposer(); + var page = new DashboardPageModel( + "Operations", + [ + new DashboardZoneModel( + "top", + "Top Zone", + UiLayout.Grid, + [new DashboardPanelModel("p1", "Revenue", "
    $100
    ", UiTone.Success)]) + ]); + + var html = composer.Compose(page); + + Assert.Contains("Operations", html); + Assert.Contains("Top Zone", html); + Assert.Contains("$100", html); + } + + [Fact] + public void DashboardShell_ShouldRenderShellClasses() + { + var component = new Cd311DashboardShellViewComponent(new TablerSemanticClassMapper()); + var result = Assert.IsType(component.Invoke(new DashboardPageModel("Sales", []), UiLayout.Grid)); + + using var writer = new StringWriter(); + result.Content.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); + var html = writer.ToString(); + + Assert.Contains("cd311-dashboard-shell", html); + Assert.Contains("Sales", html); + } + + [Fact] + public void KpiSummary_ShouldRenderToneMappedItems() + { + var component = new Cd311KpiSummaryViewComponent(new TablerSemanticClassMapper()); + var result = Assert.IsType(component.Invoke("KPIs", [new DashboardKpiItem("Users", "100", UiTone.Info, "+5%") ])); + + using var writer = new StringWriter(); + result.Content.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); + var html = writer.ToString(); + + Assert.Contains("text-info", html); + Assert.Contains("Users", html); + } + + [Fact] + public void QuickActionsPanel_ShouldRenderButtons() + { + var component = new Cd311QuickActionsPanelViewComponent(new TablerSemanticClassMapper()); + var result = Assert.IsType(component.Invoke("Actions", [new DashboardQuickAction(new("Create"), UiTone.Accent)])); + + using var writer = new StringWriter(); + result.Content.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); + var html = writer.ToString(); + + Assert.Contains("btn-primary", html); + Assert.Contains("Create", html); + } + + [Fact] + public void ActivityPanel_ShouldRenderFeedItems() + { + var component = new Cd311ActivityPanelViewComponent(new TablerSemanticClassMapper()); + var result = Assert.IsType(component.Invoke("Activity", [new DashboardActivityItem("Imported orders", DateTimeOffset.UtcNow, UiTone.Warning)])); + + using var writer = new StringWriter(); + result.Content.WriteTo(writer, System.Text.Encodings.Web.HtmlEncoder.Default); + var html = writer.ToString(); + + Assert.Contains("Imported orders", html); + Assert.Contains("text-warning", html); + } +} diff --git a/tests/Code311.Tests.Tabler.Widgets.Calendar/CalendarWidgetTests.cs b/tests/Code311.Tests.Tabler.Widgets.Calendar/CalendarWidgetTests.cs new file mode 100644 index 0000000..25c2f25 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.Calendar/CalendarWidgetTests.cs @@ -0,0 +1,36 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.Calendar.Options; +using Code311.Tabler.Widgets.Calendar.Widgets; +using Xunit; + +namespace Code311.Tests.Tabler.Widgets.Calendar; + +public sealed class CalendarWidgetTests +{ + [Fact] + public void Builder_ShouldCreateConfiguredOptions() + { + var options = new CalendarWidgetOptionsBuilder() + .WithInitialView("timeGridWeek") + .ShowWeekends(false) + .AllowEditing(true) + .Build(); + + Assert.Equal("timeGridWeek", options.InitialView); + Assert.False(options.WeekendsVisible); + Assert.True(options.Editable); + } + + [Fact] + public void Slot_ShouldExposeAssetsAndInitialization() + { + var slot = new CalendarWidgetSlot("calendar:operations", "panel-body", new CalendarWidgetOptions()); + + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Style); + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Script); + + var init = slot.CreateInitialization("operations-calendar"); + Assert.Equal("calendar:operations", init.WidgetKey); + Assert.Contains("initialView", init.OptionsJson); + } +} diff --git a/tests/Code311.Tests.Tabler.Widgets.Calendar/Code311.Tests.Tabler.Widgets.Calendar.csproj b/tests/Code311.Tests.Tabler.Widgets.Calendar/Code311.Tests.Tabler.Widgets.Calendar.csproj new file mode 100644 index 0000000..b56b066 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.Calendar/Code311.Tests.Tabler.Widgets.Calendar.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Widgets.Charts/ChartWidgetTests.cs b/tests/Code311.Tests.Tabler.Widgets.Charts/ChartWidgetTests.cs new file mode 100644 index 0000000..db85614 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.Charts/ChartWidgetTests.cs @@ -0,0 +1,36 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.Charts.Options; +using Code311.Tabler.Widgets.Charts.Widgets; +using Xunit; + +namespace Code311.Tests.Tabler.Widgets.Charts; + +public sealed class ChartWidgetTests +{ + [Fact] + public void Builder_ShouldCreateConfiguredOptions() + { + var options = new ChartWidgetOptionsBuilder() + .WithType("bar") + .ShowLegend(false) + .UseResponsiveLayout(true) + .Build(); + + Assert.Equal("bar", options.ChartType); + Assert.False(options.LegendVisible); + Assert.True(options.Responsive); + } + + [Fact] + public void Slot_ShouldExposeAssetsAndInitialization() + { + var slot = new ChartWidgetSlot("chart:kpi", "panel-body", new ChartWidgetOptions()); + + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Style); + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Script); + + var init = slot.CreateInitialization("kpi-chart"); + Assert.Equal("chart:kpi", init.WidgetKey); + Assert.Contains("chartType", init.OptionsJson); + } +} diff --git a/tests/Code311.Tests.Tabler.Widgets.Charts/Code311.Tests.Tabler.Widgets.Charts.csproj b/tests/Code311.Tests.Tabler.Widgets.Charts/Code311.Tests.Tabler.Widgets.Charts.csproj new file mode 100644 index 0000000..4ab604d --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.Charts/Code311.Tests.Tabler.Widgets.Charts.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Widgets.DataTables/Code311.Tests.Tabler.Widgets.DataTables.csproj b/tests/Code311.Tests.Tabler.Widgets.DataTables/Code311.Tests.Tabler.Widgets.DataTables.csproj new file mode 100644 index 0000000..08e8647 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.DataTables/Code311.Tests.Tabler.Widgets.DataTables.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Tabler.Widgets.DataTables/DataTableWidgetTests.cs b/tests/Code311.Tests.Tabler.Widgets.DataTables/DataTableWidgetTests.cs new file mode 100644 index 0000000..be42fb3 --- /dev/null +++ b/tests/Code311.Tests.Tabler.Widgets.DataTables/DataTableWidgetTests.cs @@ -0,0 +1,37 @@ +using Code311.Tabler.Core.Assets; +using Code311.Tabler.Widgets.DataTables.Options; +using Code311.Tabler.Widgets.DataTables.Widgets; +using Xunit; + +namespace Code311.Tests.Tabler.Widgets.DataTables; + +public sealed class DataTableWidgetTests +{ + [Fact] + public void Builder_ShouldCreateConfiguredOptions() + { + var options = new DataTableWidgetOptionsBuilder() + .WithPageLength(50) + .EnableSearch(false) + .WithDefaultSort("createdAt", "desc") + .Build(); + + Assert.Equal(50, options.PageLength); + Assert.False(options.SearchEnabled); + Assert.Equal("createdAt", options.DefaultSortColumn); + Assert.Equal("desc", options.DefaultSortDirection); + } + + [Fact] + public void Slot_ShouldExposeAssetsAndInitialization() + { + var slot = new DataTableWidgetSlot("datatable:incidents", "panel-body", new DataTableWidgetOptions()); + + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Style); + Assert.Contains(slot.GetAssetContributions(), x => x.Type == TablerAssetType.Script); + + var init = slot.CreateInitialization("incidents-table"); + Assert.Equal("datatable:incidents", init.WidgetKey); + Assert.Contains("pageLength", init.OptionsJson); + } +} diff --git a/tests/Code311.Tests.Ui.Abstractions/Code311.Tests.Ui.Abstractions.csproj b/tests/Code311.Tests.Ui.Abstractions/Code311.Tests.Ui.Abstractions.csproj new file mode 100644 index 0000000..efdde5c --- /dev/null +++ b/tests/Code311.Tests.Ui.Abstractions/Code311.Tests.Ui.Abstractions.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Ui.Abstractions/ThemeAndPreferenceTests.cs b/tests/Code311.Tests.Ui.Abstractions/ThemeAndPreferenceTests.cs new file mode 100644 index 0000000..ecda202 --- /dev/null +++ b/tests/Code311.Tests.Ui.Abstractions/ThemeAndPreferenceTests.cs @@ -0,0 +1,59 @@ +using Xunit; +using Code311.Ui.Abstractions.Preferences; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; + +namespace Code311.Tests.Ui.Abstractions; + +/// +/// Tests abstraction model defaults and initialization behavior. +/// +/// +/// These tests validate contract-level expectations without depending on implementation packages. +/// +public sealed class ThemeAndPreferenceTests +{ + /// + /// Verifies theme profile required and default properties. + /// + /// + /// Defaults are part of the contract baseline and help produce predictable behavior. + /// + [Fact] + public void ThemeProfile_ShouldExposeExpectedDefaults() + { + var profile = new ThemeProfile { Name = "default" }; + + Assert.Equal("default", profile.Name); + Assert.Equal(UiTone.Neutral, profile.Tone); + Assert.Equal(UiDensity.Comfortable, profile.Density); + Assert.Equal(SidebarMode.Expanded, profile.SidebarMode); + Assert.Equal(NavbarStyle.Default, profile.NavbarStyle); + Assert.False(profile.DarkMode); + } + + /// + /// Verifies user preference required and default properties. + /// + /// + /// This ensures persistence consumers can rely on stable baseline values. + /// + [Fact] + public void UserUiPreference_ShouldExposeExpectedDefaults() + { + var preference = new UserUiPreference + { + TenantId = "tenant-a", + UserId = "user-1", + Theme = "default" + }; + + Assert.Equal("tenant-a", preference.TenantId); + Assert.Equal("user-1", preference.UserId); + Assert.Equal("default", preference.Theme); + Assert.Equal(UiDensity.Comfortable, preference.Density); + Assert.Equal(25, preference.DefaultPageSize); + Assert.Equal("en", preference.Language); + Assert.Equal("UTC", preference.TimeZone); + } +} diff --git a/tests/Code311.Tests.Ui.Abstractions/WidgetContractTests.cs b/tests/Code311.Tests.Ui.Abstractions/WidgetContractTests.cs new file mode 100644 index 0000000..8bc9399 --- /dev/null +++ b/tests/Code311.Tests.Ui.Abstractions/WidgetContractTests.cs @@ -0,0 +1,45 @@ +using Xunit; +using Code311.Ui.Abstractions.Widgets; + +namespace Code311.Tests.Ui.Abstractions; + +/// +/// Tests widget abstraction contracts. +/// +/// +/// The tests focus on DTO/record and enum contract behavior only. +/// +public sealed class WidgetContractTests +{ + /// + /// Verifies widget asset descriptor preserves provided values. + /// + /// + /// Deterministic descriptor behavior is required by asset manifest composition. + /// + [Fact] + public void WidgetAssetDescriptor_ShouldStoreInputValues() + { + var descriptor = new WidgetAssetDescriptor("/assets/widget.js", WidgetAssetType.Script, 20); + + Assert.Equal("/assets/widget.js", descriptor.Path); + Assert.Equal(WidgetAssetType.Script, descriptor.Type); + Assert.Equal(20, descriptor.Order); + } + + /// + /// Verifies initialization context stores tenant and user scope. + /// + /// + /// Tenant and user values are essential for multi-tenant-safe widget initialization. + /// + [Fact] + public void WidgetInitializationContext_ShouldStoreScopeValues() + { + var context = new WidgetInitializationContext("charts.sales", "tenant-a", "user-1"); + + Assert.Equal("charts.sales", context.WidgetKey); + Assert.Equal("tenant-a", context.TenantId); + Assert.Equal("user-1", context.UserId); + } +} diff --git a/tests/Code311.Tests.Ui.Core/.gitkeep b/tests/Code311.Tests.Ui.Core/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Ui.Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Code311.Tests.Ui.Core/Code311.Tests.Ui.Core.csproj b/tests/Code311.Tests.Ui.Core/Code311.Tests.Ui.Core.csproj new file mode 100644 index 0000000..1bcc42c --- /dev/null +++ b/tests/Code311.Tests.Ui.Core/Code311.Tests.Ui.Core.csproj @@ -0,0 +1,17 @@ + + + net10.0 + true + + + + + + + + + + + + + diff --git a/tests/Code311.Tests.Ui.Core/ThemeAndOrchestrationTests.cs b/tests/Code311.Tests.Ui.Core/ThemeAndOrchestrationTests.cs new file mode 100644 index 0000000..840b467 --- /dev/null +++ b/tests/Code311.Tests.Ui.Core/ThemeAndOrchestrationTests.cs @@ -0,0 +1,121 @@ +using Code311.Ui.Abstractions.Options; +using Code311.Ui.Abstractions.Semantics; +using Code311.Ui.Abstractions.Theming; +using Code311.Ui.Core.Feedback; +using Code311.Ui.Core.Loading; +using Code311.Ui.Core.Theming; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Code311.Tests.Ui.Core; + +/// +/// Validates neutral orchestration behavior in Code311.Ui.Core. +/// +/// +/// Tests ensure core behavior remains semantic and design-system-agnostic. +/// +public sealed class ThemeAndOrchestrationTests +{ + /// + /// Ensures resolver returns explicit theme when present. + /// + /// + /// Explicit registration should win over fallback behavior. + /// + [Fact] + public async Task ThemeResolver_ShouldReturnRegisteredThemeAsync() + { + var registry = new ThemeRegistry(); + registry.Register(new ThemeProfile { Name = "enterprise", Tone = UiTone.Accent }); + + var resolver = new DefaultThemeProfileResolver( + registry, + Options.Create(new UiFrameworkOptions { DefaultDensity = UiDensity.Spacious })); + + var result = await resolver.ResolveAsync("enterprise"); + + Assert.NotNull(result); + Assert.Equal("enterprise", result!.Name); + Assert.Equal(UiTone.Accent, result.Tone); + } + + /// + /// Ensures fallback theme uses framework options when explicit theme is missing. + /// + /// + /// Fallback behavior must remain deterministic. + /// + [Fact] + public async Task ThemeResolver_ShouldReturnFallbackThemeAsync() + { + var resolver = new DefaultThemeProfileResolver( + new ThemeRegistry(), + Options.Create(new UiFrameworkOptions { DefaultDensity = UiDensity.Compact })); + + var result = await resolver.ResolveAsync("unknown"); + + Assert.NotNull(result); + Assert.Equal("default", result!.Name); + Assert.Equal(UiDensity.Compact, result.Density); + } + + /// + /// Ensures feedback channel drains messages in FIFO order. + /// + /// + /// Order preservation is important for predictable UX sequencing. + /// + [Fact] + public void FeedbackChannel_ShouldDrainInOrder() + { + var channel = new InMemoryFeedbackChannel(); + channel.Publish(new FeedbackMessage(UiTone.Info, "first", null, DateTimeOffset.UtcNow)); + channel.Publish(new FeedbackMessage(UiTone.Warning, "second", null, DateTimeOffset.UtcNow)); + + var drained = channel.Drain(); + + Assert.Collection( + drained, + x => Assert.Equal("first", x.Message), + x => Assert.Equal("second", x.Message)); + Assert.Empty(channel.Drain()); + } + + /// + /// Ensures busy scope increments and decrements as expected. + /// + /// + /// Scope disposal should clear busy state for the tracked key. + /// + [Fact] + public void BusyStateCoordinator_ShouldTrackScopes() + { + var coordinator = new BusyStateCoordinator(); + + using (coordinator.EnterScope("save")) + { + Assert.True(coordinator.IsBusy("save")); + } + + Assert.False(coordinator.IsBusy("save")); + } + + /// + /// Ensures preloader activation and completion update state. + /// + /// + /// Start/complete orchestration must be idempotent and predictable. + /// + [Fact] + public void PreloaderOrchestrator_ShouldTrackActivation() + { + var orchestrator = new PreloaderOrchestrator(); + + orchestrator.Start("route-load"); + Assert.True(orchestrator.IsActive("route-load")); + + orchestrator.Complete("route-load"); + Assert.False(orchestrator.IsActive("route-load")); + } +} diff --git a/tests/Code311.Tests.Widgets/.gitkeep b/tests/Code311.Tests.Widgets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/Code311.Tests.Widgets/.gitkeep @@ -0,0 +1 @@ +