From 03e1b653891580223c478746a6b3bccca429a050 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 18 Oct 2026 14:41:59 +0200 Subject: [PATCH 1/2] [Refactor] Blazor Blueprint app shell and Blazor Web App hosting - Move from _Host.cshtml to the Blazor Web App model: App.razor is the host page, Routes.razor the router, InteractiveServer render mode. - Replace the Radzen layout and the BlazorStrap off-canvas menu with a Blazor Blueprint sidebar, a header with About, GitHub and a light/dark toggle, and a theme in wwwroot/css/theme.css. - Remove BlazorStrap.V5. Pages still use Radzen and the Bootstrap grid; a bridge section in app.css maps the Radzen theme onto the Blueprint theme so they follow light and dark mode until they are migrated. - Import Blazor Blueprint per folder (Shared/_Imports.razor), because Radzen and Blueprint define types with the same names. - SideMenu unsubscribes from ReqIfChanged on dispose and re-renders on the renderer's thread. - Fix launchSettings.json: dotnetRunMessages must be a boolean, otherwise dotnet run ignores the profile and runs outside Development. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- reqifviewer/App.razor | 89 ++++-- reqifviewer/Pages/_Host.cshtml | 62 ----- reqifviewer/Program.cs | 20 +- reqifviewer/Properties/launchSettings.json | 58 ++-- reqifviewer/Routes.razor | 27 ++ reqifviewer/Shared/MainLayout.razor | 196 +++++-------- reqifviewer/Shared/MainLayout.razor.css | 109 +++----- reqifviewer/Shared/SideMenu.razor | 309 ++++++++++++++------- reqifviewer/Shared/SideMenu.razor.css | 50 ++++ reqifviewer/Shared/_Imports.razor | 7 + reqifviewer/_Imports.razor | 34 ++- reqifviewer/reqifviewer.csproj | 3 +- reqifviewer/wwwroot/css/app.css | 168 +++++++---- reqifviewer/wwwroot/css/theme.css | 151 ++++++++++ 15 files changed, 784 insertions(+), 507 deletions(-) delete mode 100644 reqifviewer/Pages/_Host.cshtml create mode 100644 reqifviewer/Routes.razor create mode 100644 reqifviewer/Shared/SideMenu.razor.css create mode 100644 reqifviewer/Shared/_Imports.razor create mode 100644 reqifviewer/wwwroot/css/theme.css diff --git a/CLAUDE.md b/CLAUDE.md index 8a70c3b..c87c497 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,8 +67,12 @@ URLs follow `/reqif/{header-identifier}//`. The `Spec ### Layout / UI stack -- `App.razor` → `Shared/MainLayout.razor` (Radzen layout + a `BSOffCanvas` from BlazorStrap V5 for the `SideMenu`). -- UI libraries: **Radzen.Blazor**, **BlazorStrap.V5**, plus **Blazor-Analytics** (Google Analytics ID hard-coded in `Program.cs`). +- Hosting is the Blazor Web App model: `App.razor` is the host page, `Routes.razor` the router, and the whole app runs with the `InteractiveServer` render mode (`MapRazorComponents().AddInteractiveServerRenderMode()` in `Program.cs`). +- The UI is moving from **Radzen.Blazor** to **[Blazor Blueprint](https://blazorblueprintui.com)** (`BlazorBlueprint.Components`, Lucide icons). The shell is already Blueprint: `Shared/MainLayout.razor` (`BbSidebarProvider`, header with `BbDarkModeToggle`) and `Shared/SideMenu.razor` (`BbSidebar`). The pages still use Radzen components and the Bootstrap grid. +- Both libraries define types with the same names (`DialogService`, `ButtonSize`, ...), so Blueprint is imported per folder: `Shared/_Imports.razor`. Add the same usings to a folder when you migrate its pages; move them to the root `_Imports.razor` once Radzen is removed. +- Theme: `wwwroot/css/theme.css` holds the Blueprint CSS variables for light (`:root`) and dark (`.dark`). The "bridge" section at the end of `wwwroot/css/app.css` maps the Radzen theme variables onto them and undoes Bootstrap's global link and list styles in the sidebar; delete it when Bootstrap and Radzen are gone. +- There is no Tailwind build: `blazorblueprint.css` only contains `bb:`-prefixed utilities used by the components, so layout CSS for our own markup goes in scoped `.razor.css` files. +- **Blazor-Analytics** (Google Analytics ID hard-coded in `Program.cs`). - Logging: **Serilog** configured from `appsettings.json`; rolling file sink writes to `logs/log-reqifviewer-*.txt`. Use `Log.ForContext()` rather than injecting `ILogger` (this is the established pattern). - The Dockerfile runs as a non-root user (`$APP_UID`) on Alpine; anything that needs to write must go under `/app` (e.g. `/app/logs`, which is created in the image). diff --git a/reqifviewer/App.razor b/reqifviewer/App.razor index 74c02b3..ba4c0cc 100644 --- a/reqifviewer/App.razor +++ b/reqifviewer/App.razor @@ -1,27 +1,62 @@ - - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
- + + +@using static Microsoft.AspNetCore.Components.Web.RenderMode + + + + + + + + + + + + + + @* Bootstrap and Radzen stay until the pages are migrated to Blazor Blueprint. *@ + + + + + @* The theme must load before blazorblueprint.css. *@ + + + + + + + @* Applies the saved light or dark theme before the first paint. Must stay a classic blocking script. *@ + + + + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/reqifviewer/Pages/_Host.cshtml b/reqifviewer/Pages/_Host.cshtml deleted file mode 100644 index 849b59d..0000000 --- a/reqifviewer/Pages/_Host.cshtml +++ /dev/null @@ -1,62 +0,0 @@ -@page "/" - - -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Mvc.TagHelpers -@using reqifviewer -@namespace ReqifViewer.Pages - -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - - - - - - - - - - - - - ReqIF Viewer - - - - - - - - - - - - -
- An unhandled error has occurred. - Reload - 🗙 -
- - - - - - - - - diff --git a/reqifviewer/Program.cs b/reqifviewer/Program.cs index e414214..b995557 100644 --- a/reqifviewer/Program.cs +++ b/reqifviewer/Program.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------- // // // Copyright 2021-2026 Starion Group S.A. @@ -24,7 +24,7 @@ namespace reqifviewer using Blazor.Analytics; - using BlazorStrap; + using BlazorBlueprint.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; @@ -56,8 +56,8 @@ public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - builder.Services.AddRazorPages(); - builder.Services.AddServerSideBlazor(); + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); builder.Host.UseSerilog((hostingContext, loggerConfiguration) => { @@ -67,14 +67,14 @@ public static async Task Main(string[] args) }); builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddGoogleAnalytics("295704041"); - builder.Services.AddBlazorStrap(); + builder.Services.AddBlazorBlueprintComponents(); builder.Services.AddHttpClient(); var app = builder.Build(); @@ -94,10 +94,10 @@ public static async Task Main(string[] args) logger.LogInformation("################################################################"); app.UseStaticFiles(); - app.UseRouting(); - app.MapBlazorHub(); - app.MapRazorPages(); - app.MapFallbackToPage("/_Host"); + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); await app.RunAsync(); } diff --git a/reqifviewer/Properties/launchSettings.json b/reqifviewer/Properties/launchSettings.json index 0ad6d18..8bb1fc2 100644 --- a/reqifviewer/Properties/launchSettings.json +++ b/reqifviewer/Properties/launchSettings.json @@ -1,29 +1,29 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:58037", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "reqifviewer": { - "commandName": "Project", - "dotnetRunMessages": "true", - "launchBrowser": true, - "applicationUrl": "http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_DETAILEDERRORS": "true" - } - } - } -} +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58037", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "reqifviewer": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_DETAILEDERRORS": "true" + } + } + } +} diff --git a/reqifviewer/Routes.razor b/reqifviewer/Routes.razor new file mode 100644 index 0000000..3ea0242 --- /dev/null +++ b/reqifviewer/Routes.razor @@ -0,0 +1,27 @@ + + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
+ diff --git a/reqifviewer/Shared/MainLayout.razor b/reqifviewer/Shared/MainLayout.razor index 6a77115..371a64f 100644 --- a/reqifviewer/Shared/MainLayout.razor +++ b/reqifviewer/Shared/MainLayout.razor @@ -1,123 +1,73 @@ - - -@using System.Reflection; -@using Pages.Index; - -@inherits LayoutComponentBase - -@inject NavigationManager NavigationManager -@inject IJSRuntime JSRuntime -@inject DialogService DialogService - - - - - - - - - -
-
- - - -
-
-
- About -
- - - - - -
-
-
-
- - - - - - - - - - - @Body - - - - - - -
-

Copyright © Starion Group S.A. 2021-2026 - version @version

-
-
-
-
- -@code { - - /// - /// is a sidebar component that can be toggled via JavaScript to appear from the left, right, or bottom edge of the viewport - /// - private BlazorStrap.V5.BSOffCanvas offCanvas; - - /// - /// The version of the application - /// - private string version = string.Empty; - - /// - /// Method invoked when the component is ready to start, having received its - /// initial parameters from its parent in the render tree. - /// Override this method if you will perform an asynchronous operation and - /// want the component to refresh when that operation is completed. - /// - protected override void OnInitialized() - { - this.version = Assembly.GetExecutingAssembly(). - GetCustomAttribute(). - InformationalVersion; - } - - /// - /// Show the About dialog - /// - /// - /// an awaitable - /// - public async Task OpenAbout() - { - await DialogService.OpenAsync("About ReqIF Viewer",null, new DialogOptions() { Width = "700px", Height = "700px", Resizable = false, Draggable = false }); - } - - /// - /// Opens the menu - /// - private async Task OpenCanvas() - { - if (this.offCanvas != null) - { - await offCanvas.ToggleAsync(); - } - } -} + + +@using Pages.Index + +@inherits LayoutComponentBase + +@inject Radzen.DialogService RadzenDialogService + +ReqIF Viewer + +@* Radzen hosts stay until the pages are migrated to Blazor Blueprint. *@ + + + + + + + + + +
+ + ReqIF Viewer + +
+ + + About + + + + + +
+
+ +
+ @Body +
+
+
+ + + + + +@code { + + /// + /// Show the About dialog + /// + /// + /// an awaitable + /// + public async Task OpenAbout() + { + await this.RadzenDialogService.OpenAsync("About ReqIF Viewer", null, new Radzen.DialogOptions { Width = "700px", Height = "700px", Resizable = false, Draggable = false }); + } +} diff --git a/reqifviewer/Shared/MainLayout.razor.css b/reqifviewer/Shared/MainLayout.razor.css index 5c663e0..750468b 100644 --- a/reqifviewer/Shared/MainLayout.razor.css +++ b/reqifviewer/Shared/MainLayout.razor.css @@ -1,70 +1,39 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -.main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - } - - .top-row a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row:not(.auth) { - display: none; - } - - .top-row.auth { - justify-content: space-between; - } - - .top-row a, .top-row .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .main > div { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} +.app-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 0.75rem; + height: 3.5rem; + padding: 0 1rem; + border-bottom: 1px solid var(--border); + background-color: var(--background); +} + +.app-header-title { + font-weight: 600; + color: var(--foreground); +} + +.app-header-actions { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; +} + +.app-content { + flex: 1; + padding: 1.5rem; +} + +@media (min-width: 768px) { + .app-content { + padding: 2rem; + } +} + +.app-header-actions ::deep a { + color: inherit; +} diff --git a/reqifviewer/Shared/SideMenu.razor b/reqifviewer/Shared/SideMenu.razor index f088dc2..c1eed8d 100644 --- a/reqifviewer/Shared/SideMenu.razor +++ b/reqifviewer/Shared/SideMenu.razor @@ -1,104 +1,205 @@ - - -@using System.Collections.Generic - -@using ReqIFSharp -@using ReqIFSharp.Extensions.Services - -@inject IReqIFLoaderService reqIfLoaderService - - - - - @if (this.reqifLoaded) - { - @foreach (var reqif in this.reqIfLoaderService.ReqIFData) - { - - - - - @if (reqif != null) - { - @foreach (var specification in reqif.CoreContent.Specifications) - { - - } - } - - - - - - - - - - - - - - - } - } - - -@code { - - /// - /// a value indicating whether a ReqIF file is loaded or not - /// - private bool reqifLoaded = false; - - /// - /// Invoked when the component is initialized after having received its initial parameters - /// - protected override void OnInitialized() - { - if (this.reqIfLoaderService.ReqIFData == null || !this.reqIfLoaderService.ReqIFData.Any()) - { - this.reqifLoaded = false; - } - else - { - this.reqifLoaded = true; - } - - this.reqIfLoaderService.ReqIfChanged += ReqIfLoaderServiceOnReqIfChanged; - } - - /// - /// Handler for the event - /// - /// an instance of - /// - /// The that has been loaded - /// - private void ReqIfLoaderServiceOnReqIfChanged(object sender, IEnumerable reqIfs) - { - if (reqIfs == null || !reqIfs.Any()) - { - this.reqifLoaded = false; - } - else - { - this.reqifLoaded = true; - } - - this.StateHasChanged(); - } -} + + +@using System.Collections.Generic +@using System.Reflection + +@using ReqIFSharp +@using ReqIFSharp.Extensions.Services + +@implements IDisposable + +@inject IReqIFLoaderService ReqIfLoaderService + + + + + + + +
+ ReqIF Viewer + powered by ReqIFSharp +
+
+
+
+
+ + + + + + + + Open a file + + + + + + @foreach (var reqif in this.loadedReqIfs) + { + var root = $"reqif/{reqif.TheHeader.Identifier}"; + + + @(string.IsNullOrWhiteSpace(reqif.TheHeader.Title) ? "ReqIF document" : reqif.TheHeader.Title) + + + + + Statistics + + + + + + + + Specifications + + + + + + + @foreach (var specification in reqif.CoreContent.Specifications) + { + + + @(string.IsNullOrWhiteSpace(specification.LongName) ? specification.Identifier : specification.LongName) + + + } + + + + + + @foreach (var item in ContentItems) + { + + + + @item.Text + + + } + + + + + + Types + + + + + + + @foreach (var item in TypeItems) + { + + + @item.Text + + + } + + + + + + + } + + + + + +
+ +@code { + + /// + /// The pages that show the content of a ReqIF document: menu text, Lucide icon and path below /reqif/{identifier} + /// + private static readonly (string Text, string Icon, string Path)[] ContentItems = + [ + ("Spec Objects", "file-text", "specobjects"), + ("Spec Relations", "link", "specrelations"), + ("Relation Matrix", "grid-3x3", "relationmatrix"), + ("External Objects", "images", "externalobjects") + ]; + + /// + /// The pages that show the types of a ReqIF document: menu text and path below /reqif/{identifier} + /// + private static readonly (string Text, string Path)[] TypeItems = + [ + ("Data Types", "datatypedefinitions"), + ("Spec Object Types", "spectypes?type=SpecObjectType"), + ("Specification Types", "spectypes?type=SpecificationType"), + ("Spec Relation Types", "spectypes?type=SpecRelationType"), + ("Relation Group Types", "spectypes?type=RelationGroupType") + ]; + + /// + /// The loaded ReqIF documents, empty when no file is loaded + /// + private List loadedReqIfs = []; + + /// + /// The version of the application, without the source revision + /// + private string version = string.Empty; + + /// + /// Invoked when the component is initialized after having received its initial parameters + /// + protected override void OnInitialized() + { + var informationalVersion = Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion ?? string.Empty; + this.version = informationalVersion.Split('+')[0]; + + this.loadedReqIfs = this.ReqIfLoaderService.ReqIFData?.Where(x => x != null).ToList() ?? []; + this.ReqIfLoaderService.ReqIfChanged += this.ReqIfLoaderServiceOnReqIfChanged; + } + + /// + /// Handler for the event + /// + /// an instance of + /// + /// The that has been loaded + /// + private void ReqIfLoaderServiceOnReqIfChanged(object sender, IEnumerable reqIfs) + { + this.loadedReqIfs = reqIfs?.Where(x => x != null).ToList() ?? []; + this.InvokeAsync(this.StateHasChanged); + } + + /// + /// Unsubscribes from the event + /// + public void Dispose() + { + this.ReqIfLoaderService.ReqIfChanged -= this.ReqIfLoaderServiceOnReqIfChanged; + } +} diff --git a/reqifviewer/Shared/SideMenu.razor.css b/reqifviewer/Shared/SideMenu.razor.css new file mode 100644 index 0000000..fb03dbc --- /dev/null +++ b/reqifviewer/Shared/SideMenu.razor.css @@ -0,0 +1,50 @@ +.side-menu-logo { + width: 2rem; + height: 2rem; + flex-shrink: 0; + border-radius: 0.5rem; +} + +.side-menu-brand { + display: grid; + flex: 1; + min-width: 0; + line-height: 1.2; +} + +.side-menu-brand-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.side-menu-brand-subtitle { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.side-menu-footer { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.side-menu-footer a { + color: inherit; + text-decoration: underline; +} + +/* Collapsed to the icon rail: keep only the logo, at icon size, and hide the text. */ +[data-state="collapsed"] .side-menu-logo { + width: 1.5rem; + height: 1.5rem; +} + +[data-state="collapsed"] .side-menu-brand, +[data-state="collapsed"] .side-menu-footer { + display: none; +} diff --git a/reqifviewer/Shared/_Imports.razor b/reqifviewer/Shared/_Imports.razor new file mode 100644 index 0000000..7eacc6e --- /dev/null +++ b/reqifviewer/Shared/_Imports.razor @@ -0,0 +1,7 @@ +@* Blazor Blueprint is imported per folder while the app moves from Radzen to Blazor Blueprint: + both libraries define types with the same names, such as DialogService. Add these usings to a + folder once its pages are migrated, and move them to the root _Imports.razor when Radzen is gone. *@ +@using BlazorBlueprint.Components +@using BlazorBlueprint.Primitives +@using BlazorBlueprint.Icons.Lucide.Components +@using ButtonSize = BlazorBlueprint.Components.ButtonSize diff --git a/reqifviewer/_Imports.razor b/reqifviewer/_Imports.razor index f52fd67..4903141 100644 --- a/reqifviewer/_Imports.razor +++ b/reqifviewer/_Imports.razor @@ -1,18 +1,16 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using System.Security.Claims -@using Microsoft.AspNetCore.Components; -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.JSInterop -@using reqifviewer -@using reqifviewer.Shared -@using reqifviewer.Pages -@using Radzen -@using Radzen.Blazor -@using Blazor.Analytics -@using Blazor.Analytics.Components -@using BlazorStrap -@using BlazorStrap.V5 \ No newline at end of file +@using System.Net.Http +@using System.Net.Http.Json +@using System.Security.Claims +@using Microsoft.AspNetCore.Components; +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using reqifviewer +@using reqifviewer.Shared +@using reqifviewer.Pages +@using Radzen +@using Radzen.Blazor +@using Blazor.Analytics +@using Blazor.Analytics.Components diff --git a/reqifviewer/reqifviewer.csproj b/reqifviewer/reqifviewer.csproj index 7aeb8ef..8c77f63 100644 --- a/reqifviewer/reqifviewer.csproj +++ b/reqifviewer/reqifviewer.csproj @@ -33,9 +33,10 @@ + + - diff --git a/reqifviewer/wwwroot/css/app.css b/reqifviewer/wwwroot/css/app.css index ba5c365..7f13447 100644 --- a/reqifviewer/wwwroot/css/app.css +++ b/reqifviewer/wwwroot/css/app.css @@ -1,61 +1,107 @@ -@import url('open-iconic/font/css/open-iconic-bootstrap.min.css'); - -html, body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -a, .btn-link { - color: #0366d6; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.content { - padding-top: 1.1rem; -} - -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; -} - -.invalid { - outline: 1px solid red; -} - -.validation-message { - color: red; -} - -#blazor-error-ui { - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } - -.rz-footer { - min-height: 0; - padding: 4px 16px; -} - -.rz-footer p { - margin: 0; - font-size: 0.75rem; - line-height: 1.3; -} +@import url('open-iconic/font/css/open-iconic-bootstrap.min.css'); + +a, .btn-link { + color: var(--primary); +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +/* The large logo button in the sidebar header keeps too much padding when the sidebar is collapsed + * to the icon rail, which squeezes the logo. The button is rendered by Blazor Blueprint, so scoped + * CSS in SideMenu.razor.css cannot reach it. */ +[data-state="collapsed"] [data-sidebar="menu-button"][data-size="lg"] { + padding: 0.25rem; +} + +/* --------------------------------------------------------------------------------------------- + * Bridge between Bootstrap, Radzen and Blazor Blueprint while the pages are migrated. + * Bootstrap and Radzen styles are not in a cascade layer, so they win over the layered Blazor + * Blueprint styles. The rules below restore the Blueprint look where they collide, and point the + * Radzen theme variables at the Blueprint theme so Radzen content follows light and dark mode. + * Remove this section once Bootstrap and Radzen are gone. + * --------------------------------------------------------------------------------------------- */ + +html, +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} + +[data-sidebar] a, +[data-sidebar] a:hover { + color: inherit; + text-decoration: none; +} + +ul[data-sidebar="menu"] { + margin: 0; + padding-left: 0; +} + +ul[data-sidebar="menu-sub"] { + margin-bottom: 0; +} + +:root { + --rz-white: var(--card); + --rz-black: var(--foreground); + --rz-base-50: var(--background); + --rz-base-100: color-mix(in oklch, var(--foreground) 4%, var(--background)); + --rz-base-200: color-mix(in oklch, var(--foreground) 8%, var(--background)); + --rz-base-300: var(--border); + --rz-base-400: color-mix(in oklch, var(--foreground) 25%, var(--background)); + --rz-base-500: color-mix(in oklch, var(--foreground) 45%, var(--background)); + --rz-base-600: var(--muted-foreground); + --rz-base-700: color-mix(in oklch, var(--foreground) 75%, var(--background)); + --rz-base-800: color-mix(in oklch, var(--foreground) 88%, var(--background)); + --rz-base-900: var(--foreground); + --rz-on-base: var(--foreground); + --rz-base-background-color: var(--card); + --rz-body-background-color: var(--background); + --rz-primary: var(--primary); + --rz-on-primary: var(--primary-foreground); + --rz-primary-light: color-mix(in oklch, var(--primary) 85%, white); + --rz-primary-lighter: color-mix(in oklch, var(--primary) 16%, transparent); + --rz-primary-dark: color-mix(in oklch, var(--primary) 85%, black); + --rz-primary-darker: color-mix(in oklch, var(--primary) 70%, black); +} diff --git a/reqifviewer/wwwroot/css/theme.css b/reqifviewer/wwwroot/css/theme.css new file mode 100644 index 0000000..84208e6 --- /dev/null +++ b/reqifviewer/wwwroot/css/theme.css @@ -0,0 +1,151 @@ +/* ReqIF Viewer theme for Blazor Blueprint. + * Based on the theme of the Blazor Blueprint demo application (Apache License 2.0), + * with a blue primary colour. Light values are on :root, dark values on .dark. + * Generate alternatives with the Blazor Blueprint theme playground or tweakcn.com. + */ +@layer base { + /* Light Mode (Default) */ +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.1450 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.1450 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.1450 0 0); + --primary: oklch(0.5300 0.1500 253); + --primary-foreground: oklch(0.9850 0 0); + --secondary: oklch(0.9700 0 0); + --secondary-foreground: oklch(0.2050 0 0); + --muted: oklch(0.9700 0 0); + --muted-foreground: oklch(0.5560 0 0); + --accent: oklch(0.9700 0 0); + --accent-foreground: oklch(0.2050 0 0); + --destructive: oklch(0.5770 0.2450 27.3250); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.9220 0 0); + --input: oklch(0.9220 0 0); + --ring: oklch(0.6200 0.1300 253); + --chart-1: oklch(0.8100 0.1000 252); + --chart-2: oklch(0.6200 0.1900 260); + --chart-3: oklch(0.5500 0.2200 263); + --chart-4: oklch(0.4900 0.2200 264); + --chart-5: oklch(0.4200 0.1800 266); + --sidebar: oklch(0.9800 0.0040 253); + --sidebar-foreground: oklch(0.1450 0 0); + --sidebar-primary: oklch(0.5300 0.1500 253); + --sidebar-primary-foreground: oklch(0.9850 0 0); + --sidebar-accent: oklch(0.9400 0.0150 253); + --sidebar-accent-foreground: oklch(0.3000 0.0800 253); + --sidebar-border: oklch(0.9220 0 0); + --sidebar-ring: oklch(0.7080 0 0); + --alert-success: oklch(0.55 0.20 142); + --alert-success-foreground: oklch(0.30 0.09 142); + --alert-success-bg: oklch(0.993 0.003 142); + --alert-info: oklch(0.50 0.20 255); + --alert-info-foreground: oklch(0.30 0.10 255); + --alert-info-bg: oklch(0.993 0.003 255); + --alert-warning: oklch(0.68 0.18 55); + --alert-warning-foreground: oklch(0.35 0.10 55); + --alert-warning-bg: oklch(0.995 0.003 55); + --alert-danger: oklch(0.55 0.22 27); + --alert-danger-foreground: oklch(0.30 0.12 27); + --alert-danger-bg: oklch(0.993 0.003 27); + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 0.625rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: oklch(0 0 0); + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0.1450 0 0); + --foreground: oklch(0.9850 0 0); + --card: oklch(0.2050 0 0); + --card-foreground: oklch(0.9850 0 0); + --popover: oklch(0.2690 0 0); + --popover-foreground: oklch(0.9850 0 0); + --primary: oklch(0.7200 0.1300 250); + --primary-foreground: oklch(0.1800 0.0300 253); + --secondary: oklch(0.2690 0 0); + --secondary-foreground: oklch(0.9850 0 0); + --muted: oklch(0.2690 0 0); + --muted-foreground: oklch(0.7080 0 0); + --accent: oklch(0.3710 0 0); + --accent-foreground: oklch(0.9850 0 0); + --destructive: oklch(0.7040 0.1910 22.2160); + --destructive-foreground: oklch(0.9850 0 0); + --border: oklch(0.2750 0 0); + --input: oklch(0.3250 0 0); + --ring: oklch(0.6000 0.1200 250); + --chart-1: oklch(0.8100 0.1000 252); + --chart-2: oklch(0.6200 0.1900 260); + --chart-3: oklch(0.5500 0.2200 263); + --chart-4: oklch(0.4900 0.2200 264); + --chart-5: oklch(0.4200 0.1800 266); + --sidebar: oklch(0.1900 0.0150 253); + --sidebar-foreground: oklch(0.9850 0 0); + --sidebar-primary: oklch(0.7200 0.1300 250); + --sidebar-primary-foreground: oklch(0.9850 0 0); + --sidebar-accent: oklch(0.2700 0.0300 253); + --sidebar-accent-foreground: oklch(0.9850 0 0); + --sidebar-border: oklch(0.2750 0 0); + --sidebar-ring: oklch(0.4390 0 0); + --alert-success: oklch(0.60 0.18 142); + --alert-success-foreground: oklch(0.90 0.06 142); + --alert-success-bg: oklch(0.18 0.008 142); + --alert-info: oklch(0.55 0.18 255); + --alert-info-foreground: oklch(0.90 0.05 255); + --alert-info-bg: oklch(0.18 0.008 255); + --alert-warning: oklch(0.65 0.16 55); + --alert-warning-foreground: oklch(0.92 0.06 55); + --alert-warning-bg: oklch(0.19 0.008 55); + --alert-danger: oklch(0.55 0.20 27); + --alert-danger-foreground: oklch(0.90 0.06 27); + --alert-danger-bg: oklch(0.18 0.008 27); + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --radius: 0.625rem; + --shadow-x: 0; + --shadow-y: 1px; + --shadow-blur: 3px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: oklch(0 0 0); + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); +} + + /* Global Element Styles */ + * { + border-color: var(--border); + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + letter-spacing: var(--tracking-normal); + } +} From de003d9e86f4e0bc8bf2fcfa1aebdd12baf4fe13 Mon Sep 17 00:00:00 2001 From: samatstarion Date: Sun, 18 Oct 2026 14:58:35 +0200 Subject: [PATCH 2/2] [Fix] review findings on the Blazor Blueprint shell - Unknown addresses show a "Page not found" page inside the app with a 404 status, instead of an empty 404 (UseStatusCodePagesWithReExecute and the Router's NotFoundPage). - The sidebar uses CreateUrl() for specification links and a new ExtractDisplayText() for their names, following the display-name order of ExtractDisplayName() as plain text. - "Open a file" is highlighted on the home page; an empty Href never counted as active, so the home links now use "/". - The GitHub link keeps rel="noopener noreferrer" from BbButton. - The sidebar drawer closes after navigating on small screens. - Tests for SideMenu, MainLayout, ExtractDisplayText and the hosting (host page, stylesheet order, not-found page): 24 new tests. Co-Authored-By: Claude Opus 5.5 --- reqifviewer.Tests/HostingTestFixture.cs | 91 ++++++ ...ementWithAttributesExtensionTestFixture.cs | 34 +++ .../Shared/MainLayoutTestFixture.cs | 172 ++++++++++++ .../Shared/SideMenuTestFixture.cs | 263 ++++++++++++++++++ reqifviewer.Tests/reqifviewer.Tests.csproj | 1 + reqifviewer/Pages/NotFound/NotFoundPage.razor | 25 ++ .../Pages/NotFound/NotFoundPage.razor.css | 14 + reqifviewer/Program.cs | 1 + .../SpecElementWithAttributesExtensions.cs | 35 +++ reqifviewer/Routes.razor | 9 +- reqifviewer/Shared/MainLayout.razor | 51 +++- reqifviewer/Shared/SideMenu.razor | 12 +- 12 files changed, 696 insertions(+), 12 deletions(-) create mode 100644 reqifviewer.Tests/HostingTestFixture.cs create mode 100644 reqifviewer.Tests/Shared/MainLayoutTestFixture.cs create mode 100644 reqifviewer.Tests/Shared/SideMenuTestFixture.cs create mode 100644 reqifviewer/Pages/NotFound/NotFoundPage.razor create mode 100644 reqifviewer/Pages/NotFound/NotFoundPage.razor.css diff --git a/reqifviewer.Tests/HostingTestFixture.cs b/reqifviewer.Tests/HostingTestFixture.cs new file mode 100644 index 0000000..1692fa1 --- /dev/null +++ b/reqifviewer.Tests/HostingTestFixture.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests +{ + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + using Microsoft.AspNetCore.Mvc.Testing; + + using NUnit.Framework; + + using reqifviewer; + + /// + /// Starts the whole application in memory and checks the host page and the not-found page + /// + [TestFixture] + public class HostingTestFixture + { + private WebApplicationFactory factory; + + private HttpClient client; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + this.factory = new WebApplicationFactory(); + this.client = this.factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + this.client.Dispose(); + this.factory.Dispose(); + } + + [Test] + public async Task Verify_that_the_root_returns_the_host_page() + { + var response = await this.client.GetAsync("/"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(html, Does.Contain("_framework/blazor.web.js")); + Assert.That(html, Does.Contain("ReqIF Viewer")); + } + + [Test] + public async Task Verify_that_the_theme_is_loaded_before_the_Blazor_Blueprint_stylesheet() + { + var html = await this.client.GetStringAsync("/"); + + var theme = html.IndexOf("css/theme.", System.StringComparison.Ordinal); + var blueprint = html.IndexOf("blazorblueprint.css", System.StringComparison.Ordinal); + + Assert.That(theme, Is.GreaterThanOrEqualTo(0), "theme.css is not referenced"); + Assert.That(blueprint, Is.GreaterThan(theme), "theme.css must be loaded before blazorblueprint.css"); + } + + [Test] + public async Task Verify_that_an_unknown_address_returns_the_not_found_page() + { + var response = await this.client.GetAsync("/this-page-does-not-exist"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(html, Does.Contain("Sorry, there's nothing at this address.").Or.Contain("Sorry, there's nothing at this address.")); + Assert.That(html, Does.Contain("_framework/blazor.web.js"), "the not-found page is rendered inside the app"); + } + } +} diff --git a/reqifviewer.Tests/ReqIFExtensions/SpecElementWithAttributesExtensionTestFixture.cs b/reqifviewer.Tests/ReqIFExtensions/SpecElementWithAttributesExtensionTestFixture.cs index 5692e33..ba4166b 100644 --- a/reqifviewer.Tests/ReqIFExtensions/SpecElementWithAttributesExtensionTestFixture.cs +++ b/reqifviewer.Tests/ReqIFExtensions/SpecElementWithAttributesExtensionTestFixture.cs @@ -71,5 +71,39 @@ public void Verify_that_ExtractSpecificationName_returns_expected_result() specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_DqMpsKddEeafNduaIhMwQg"); Assert.That(specObject.ExtractDisplayName(), Is.EqualTo("REQ-6")); } + + [Test] + public void Verify_that_ExtractDisplayText_returns_plain_text_in_the_same_order_as_ExtractDisplayName() + { + // The first attribute value is XHTML: its text is returned without markup + var specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_o7scQ6dbEeafNduaIhMwQg"); + Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("Stakeholder Requirements")); + + var specification = this.reqIf.CoreContent.Specifications.Single(x => x.Identifier == "_o7scS6dbEeafNduaIhMwQg"); + Assert.That(specification.ExtractDisplayText(), Is.EqualTo("Requirements Document")); + + specObject = this.reqIf.CoreContent.SpecObjects.Single(x => x.Identifier == "_DqMpsKddEeafNduaIhMwQg"); + Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("REQ-6")); + } + + [Test] + public void Verify_that_ExtractDisplayText_falls_back_to_the_identifier() + { + var withoutValues = new Specification { Identifier = "spec-1" }; + Assert.That(withoutValues.ExtractDisplayText(), Is.EqualTo("spec-1")); + + var withEmptyXhtml = new SpecObject { Identifier = "object-1" }; + withEmptyXhtml.Values.Add(new AttributeValueXHTML { TheValue = " " }); + Assert.That(withEmptyXhtml.ExtractDisplayText(), Is.EqualTo("object-1")); + } + + [Test] + public void Verify_that_ExtractDisplayText_collapses_whitespace() + { + var specObject = new SpecObject { Identifier = "object-1" }; + specObject.Values.Add(new AttributeValueXHTML { TheValue = "\n First line\n bold\n" }); + + Assert.That(specObject.ExtractDisplayText(), Is.EqualTo("First line bold")); + } } } diff --git a/reqifviewer.Tests/Shared/MainLayoutTestFixture.cs b/reqifviewer.Tests/Shared/MainLayoutTestFixture.cs new file mode 100644 index 0000000..b240084 --- /dev/null +++ b/reqifviewer.Tests/Shared/MainLayoutTestFixture.cs @@ -0,0 +1,172 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Shared +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + using BlazorBlueprint.Components; + + using Bunit; + using Bunit.TestDoubles; + + using Microsoft.AspNetCore.Components; + using Microsoft.Extensions.DependencyInjection; + + using Moq; + + using NUnit.Framework; + + using Radzen; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Pages.Index; + using reqifviewer.Shared; + + using TestContext = Bunit.TestContext; + + /// + /// Suite of tests for the component + /// + [TestFixture] + public class MainLayoutTestFixture + { + private TestContext context; + + [SetUp] + public void SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + this.context.Services.AddBlazorBlueprintComponents(); + + this.context.Services.AddScoped(); + + // The About dialog downloads the license text; answer it without going to the network + this.context.Services.AddSingleton(new HttpClient(new StubHttpMessageHandler())); + this.context.Services.AddScoped(); + this.context.Services.AddScoped(); + this.context.Services.AddScoped(); + + var reqIfLoaderService = new Mock(); + reqIfLoaderService.Setup(x => x.ReqIFData).Returns([]); + this.context.Services.AddSingleton(reqIfLoaderService.Object); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + /// + /// Answers every request with a short text, so components that download files do not reach the network + /// + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("license text") }); + } + } + + private IRenderedComponent RenderLayout() + { + return this.context.RenderComponent(parameters => parameters.Add(x => x.Body, (RenderFragment)(builder => builder.AddContent(0, "page content")))); + } + + [Test] + public void Verify_that_MainLayout_renders_the_page_body() + { + var renderer = this.RenderLayout(); + + Assert.That(renderer.Find("main.app-content").TextContent, Does.Contain("page content")); + } + + [Test] + public void Verify_that_About_button_opens_the_About_dialog() + { + string openedTitle = null; + Type openedType = null; + + var renderer = this.RenderLayout(); + + this.context.Services.GetRequiredService().OnOpen += (title, type, _, _) => + { + openedTitle = title; + openedType = type; + }; + + renderer.FindAll("header button").Single(x => x.TextContent.Trim() == "About").Click(); + + Assert.That(openedTitle, Is.EqualTo("About ReqIF Viewer")); + Assert.That(openedType, Is.EqualTo(typeof(AboutComponent))); + } + + [Test] + public void Verify_that_the_GitHub_link_opens_in_a_new_tab_without_opener_or_referrer() + { + var renderer = this.RenderLayout(); + + var link = renderer.Find("a[href=\"https://github.com/STARIONGROUP/reqifviewer\"]"); + + Assert.That(link.GetAttribute("target"), Is.EqualTo("_blank")); + Assert.That(link.GetAttribute("rel")?.Split(' '), Is.SupersetOf(new[] { "noopener", "noreferrer" })); + } + + [Test] + public void Verify_that_the_mobile_sidebar_closes_after_navigating() + { + var renderer = this.RenderLayout(); + var provider = renderer.FindComponent(); + + Assert.That(provider.Instance.OpenMobile, Is.False); + + // The user opens the drawer on a small screen + renderer.InvokeAsync(() => provider.Instance.OpenMobileChanged.InvokeAsync(true)).GetAwaiter().GetResult(); + renderer.Render(); + Assert.That(renderer.FindComponent().Instance.OpenMobile, Is.True); + + // ... and follows a link + this.context.Services.GetRequiredService().NavigateTo("reqifstatistics/doc-a"); + + renderer.WaitForAssertion(() => Assert.That(renderer.FindComponent().Instance.OpenMobile, Is.False)); + } + + [Test] + public void Verify_that_MainLayout_stops_listening_to_navigation_when_disposed() + { + this.RenderLayout(); + this.context.DisposeComponents(); + + var navigationManager = this.context.Services.GetRequiredService(); + + Assert.DoesNotThrow(() => navigationManager.NavigateTo("reqifstatistics/doc-a")); + } + } +} diff --git a/reqifviewer.Tests/Shared/SideMenuTestFixture.cs b/reqifviewer.Tests/Shared/SideMenuTestFixture.cs new file mode 100644 index 0000000..5884660 --- /dev/null +++ b/reqifviewer.Tests/Shared/SideMenuTestFixture.cs @@ -0,0 +1,263 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2021-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace ReqifViewer.Tests.Shared +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + + using BlazorBlueprint.Components; + + using Bunit; + + using Microsoft.Extensions.DependencyInjection; + + using Moq; + + using NUnit.Framework; + + using ReqIFSharp; + using ReqIFSharp.Extensions.Services; + + using reqifviewer.Shared; + + using TestContext = Bunit.TestContext; + + /// + /// Suite of tests for the component + /// + [TestFixture] + public class SideMenuTestFixture + { + private Mock reqIfLoaderService; + + private TestContext context; + + [SetUp] + public void SetUp() + { + this.context = new TestContext(); + this.context.JSInterop.Mode = JSRuntimeMode.Loose; + this.context.Services.AddBlazorBlueprintComponents(); + + this.reqIfLoaderService = new Mock(); + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([]); + this.context.Services.AddSingleton(this.reqIfLoaderService.Object); + } + + [TearDown] + public void TearDown() + { + this.context.Dispose(); + } + + /// + /// Renders the inside the that it needs + /// + private IRenderedComponent RenderSideMenu() + { + return this.context.RenderComponent(parameters => parameters.AddChildContent()); + } + + /// + /// Creates a with one , with the references between the objects set + /// as the deserializer sets them + /// + private static ReqIF CreateReqIf(string identifier, string title, string specificationLongName = "Requirements") + { + var content = new ReqIFContent(); + var reqIf = new ReqIF + { + TheHeader = new ReqIFHeader { Identifier = identifier, Title = title }, + CoreContent = content + }; + + content.DocumentRoot = reqIf; + content.Specifications.Add(new Specification { Identifier = $"{identifier}-spec", LongName = specificationLongName, ReqIFContent = content }); + + return reqIf; + } + + /// + /// Opens the collapsible "Types" group, whose items are only rendered when it is open + /// + private static void OpenTypesGroup(IRenderedComponent renderer) + { + foreach (var button in renderer.FindAll("[data-sidebar=\"menu-button\"]").Where(x => x.TextContent.Trim() == "Types").ToList()) + { + button.Click(); + } + } + + private static List GroupLabels(IRenderedComponent renderer) + { + return renderer.FindAll("[data-sidebar=\"group-label\"]").Select(x => x.TextContent.Trim()).ToList(); + } + + private static List Hrefs(IRenderedComponent renderer) + { + return renderer.FindAll("a[href]").Select(x => x.GetAttribute("href")).ToList(); + } + + [Test] + public void Verify_that_SideMenu_renders_links_for_each_loaded_ReqIF() + { + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([CreateReqIf("doc-a", "Document A"), CreateReqIf("doc-b", "Document B")]); + + var renderer = this.RenderSideMenu(); + OpenTypesGroup(renderer); + + Assert.That(GroupLabels(renderer), Is.EqualTo(new[] { "Document A", "Document B" })); + + var hrefs = Hrefs(renderer); + + foreach (var id in new[] { "doc-a", "doc-b" }) + { + Assert.That(hrefs, Does.Contain($"reqifstatistics/{id}")); + Assert.That(hrefs, Does.Contain($"/reqif/{id}/specification/{id}-spec")); + + foreach (var page in new[] { "specobjects", "specrelations", "relationmatrix", "externalobjects", "datatypedefinitions" }) + { + Assert.That(hrefs, Does.Contain($"reqif/{id}/{page}")); + } + + foreach (var type in new[] { "SpecObjectType", "SpecificationType", "SpecRelationType", "RelationGroupType" }) + { + Assert.That(hrefs, Does.Contain($"reqif/{id}/spectypes?type={type}")); + } + } + } + + [Test] + public async Task Verify_that_SideMenu_shows_documents_when_ReqIfChanged_is_raised() + { + var renderer = this.RenderSideMenu(); + + Assert.That(GroupLabels(renderer), Is.Empty); + + IEnumerable loaded = [CreateReqIf("doc-a", "Document A")]; + + // Loads finish on a thread pool thread, not on the renderer's thread + await Task.Run(() => this.reqIfLoaderService.Raise(x => x.ReqIfChanged += null, this.reqIfLoaderService.Object, loaded)); + + renderer.WaitForAssertion(() => + { + Assert.That(GroupLabels(renderer), Is.EqualTo(new[] { "Document A" })); + Assert.That(Hrefs(renderer), Does.Contain("reqifstatistics/doc-a")); + }); + + await Task.Run(() => this.reqIfLoaderService.Raise(x => x.ReqIfChanged += null, this.reqIfLoaderService.Object, (IEnumerable)null)); + renderer.WaitForAssertion(() => Assert.That(GroupLabels(renderer), Is.Empty)); + + await Task.Run(() => this.reqIfLoaderService.Raise(x => x.ReqIfChanged += null, this.reqIfLoaderService.Object, loaded)); + renderer.WaitForAssertion(() => Assert.That(GroupLabels(renderer), Has.Count.EqualTo(1))); + + await Task.Run(() => this.reqIfLoaderService.Raise(x => x.ReqIfChanged += null, this.reqIfLoaderService.Object, Enumerable.Empty())); + renderer.WaitForAssertion(() => Assert.That(GroupLabels(renderer), Is.Empty)); + } + + [Test] + public void Verify_that_SideMenu_unsubscribes_from_ReqIfChanged_on_dispose() + { + this.RenderSideMenu(); + + this.reqIfLoaderService.VerifyAdd(x => x.ReqIfChanged += It.IsAny>>(), Times.Once); + this.reqIfLoaderService.VerifyRemove(x => x.ReqIfChanged -= It.IsAny>>(), Times.Never); + + this.context.DisposeComponents(); + + this.reqIfLoaderService.VerifyRemove(x => x.ReqIfChanged -= It.IsAny>>(), Times.Once); + } + + [Test] + public async Task Verify_that_SideMenu_ignores_null_ReqIF_entries() + { + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([null, CreateReqIf("doc-a", "Document A")]); + + var renderer = this.RenderSideMenu(); + + Assert.That(GroupLabels(renderer), Is.EqualTo(new[] { "Document A" })); + + IEnumerable onlyNull = [null]; + await Task.Run(() => this.reqIfLoaderService.Raise(x => x.ReqIfChanged += null, this.reqIfLoaderService.Object, onlyNull)); + + renderer.WaitForAssertion(() => Assert.That(GroupLabels(renderer), Is.Empty)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Verify_that_SideMenu_shows_fallback_label_when_title_is_empty(string title) + { + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([CreateReqIf("doc-a", title)]); + + var renderer = this.RenderSideMenu(); + + Assert.That(GroupLabels(renderer), Is.EqualTo(new[] { "ReqIF document" })); + } + + [Test] + public void Verify_that_SideMenu_shows_the_title_when_it_is_set() + { + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([CreateReqIf("doc-a", "Coffee machine")]); + + var renderer = this.RenderSideMenu(); + + Assert.That(GroupLabels(renderer), Is.EqualTo(new[] { "Coffee machine" })); + } + + [TestCase("Requirements", "Requirements")] + [TestCase("", "doc-a-spec")] + [TestCase(null, "doc-a-spec")] + public void Verify_that_SideMenu_shows_the_display_text_of_specifications(string longName, string expected) + { + this.reqIfLoaderService.Setup(x => x.ReqIFData).Returns([CreateReqIf("doc-a", "Document A", longName)]); + + var renderer = this.RenderSideMenu(); + + var specificationItem = renderer.Find("a[href=\"/reqif/doc-a/specification/doc-a-spec\"]"); + + Assert.That(specificationItem.TextContent.Trim(), Is.EqualTo(expected)); + } + + [Test] + public void Verify_that_SideMenu_footer_shows_version_without_source_revision() + { + var renderer = this.RenderSideMenu(); + + var version = renderer.Find(".side-menu-footer span").TextContent.Trim(); + + Assert.That(version, Does.Match(@"^Version \d+\.\d+\.\d+(-[\w.]+)?$")); + Assert.That(version, Does.Not.Contain("+")); + } + + [Test] + public void Verify_that_only_Open_a_file_is_active_on_the_home_page() + { + var renderer = this.RenderSideMenu(); + + var active = renderer.FindAll("[data-sidebar=\"menu-button\"][data-active=\"true\"]").Select(x => x.TextContent.Trim()).ToList(); + + Assert.That(active, Is.EqualTo(new[] { "Open a file" })); + } + } +} diff --git a/reqifviewer.Tests/reqifviewer.Tests.csproj b/reqifviewer.Tests/reqifviewer.Tests.csproj index 8e3afd6..f1392fe 100644 --- a/reqifviewer.Tests/reqifviewer.Tests.csproj +++ b/reqifviewer.Tests/reqifviewer.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/reqifviewer/Pages/NotFound/NotFoundPage.razor b/reqifviewer/Pages/NotFound/NotFoundPage.razor new file mode 100644 index 0000000..5a07cff --- /dev/null +++ b/reqifviewer/Pages/NotFound/NotFoundPage.razor @@ -0,0 +1,25 @@ + + +@page "/not-found" + +Page not found - ReqIF Viewer + +
+

Page not found

+

Sorry, there's nothing at this address.

+

Open a ReqIF file

+
diff --git a/reqifviewer/Pages/NotFound/NotFoundPage.razor.css b/reqifviewer/Pages/NotFound/NotFoundPage.razor.css new file mode 100644 index 0000000..fe14a69 --- /dev/null +++ b/reqifviewer/Pages/NotFound/NotFoundPage.razor.css @@ -0,0 +1,14 @@ +.not-found { + max-width: 40rem; + margin: 4rem auto; + text-align: center; +} + +.not-found h1 { + font-size: 1.75rem; + font-weight: 600; +} + +.not-found p { + color: var(--muted-foreground); +} diff --git a/reqifviewer/Program.cs b/reqifviewer/Program.cs index b995557..c421579 100644 --- a/reqifviewer/Program.cs +++ b/reqifviewer/Program.cs @@ -94,6 +94,7 @@ public static async Task Main(string[] args) logger.LogInformation("################################################################"); app.UseStaticFiles(); + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents() diff --git a/reqifviewer/ReqIFExtensions/SpecElementWithAttributesExtensions.cs b/reqifviewer/ReqIFExtensions/SpecElementWithAttributesExtensions.cs index 2ecc358..dfac309 100644 --- a/reqifviewer/ReqIFExtensions/SpecElementWithAttributesExtensions.cs +++ b/reqifviewer/ReqIFExtensions/SpecElementWithAttributesExtensions.cs @@ -74,6 +74,41 @@ public static object ExtractDisplayName(this SpecElementWithAttributes specEleme } } + /// + /// Extracts the name of the as plain text, for places that cannot show + /// formatted text, such as menus and page titles + /// + /// + /// The subject + /// + /// + /// the name of the as plain text + /// + /// + /// Follows the same order as ; the text of an is + /// returned without its formatting and with its whitespace collapsed + /// + public static string ExtractDisplayText(this SpecElementWithAttributes specElementWithAttributes) + { + if (!string.IsNullOrEmpty(specElementWithAttributes.LongName)) + { + return specElementWithAttributes.LongName; + } + + var attributeValue = specElementWithAttributes.Values.FirstOrDefault(); + + var text = attributeValue switch + { + null => null, + AttributeValueXHTML attributeValueXhtml => attributeValueXhtml.ExtractUnformattedTextFromValue(), + _ => attributeValue.ObjectValue?.ToString() + }; + + text = string.Join(" ", (text ?? string.Empty).Split((char[])null, System.StringSplitOptions.RemoveEmptyEntries)); + + return string.IsNullOrEmpty(text) ? specElementWithAttributes.Identifier : text; + } + /// /// Creates the url of a /// diff --git a/reqifviewer/Routes.razor b/reqifviewer/Routes.razor index 3ea0242..d78ffc8 100644 --- a/reqifviewer/Routes.razor +++ b/reqifviewer/Routes.razor @@ -14,14 +14,11 @@ limitations under the License. -------------------------------------------------------------------------------> - +@using reqifviewer.Pages.NotFound + + - - -

Sorry, there's nothing at this address.

-
-
diff --git a/reqifviewer/Shared/MainLayout.razor b/reqifviewer/Shared/MainLayout.razor index 371a64f..7360c25 100644 --- a/reqifviewer/Shared/MainLayout.razor +++ b/reqifviewer/Shared/MainLayout.razor @@ -18,7 +18,10 @@ @inherits LayoutComponentBase +@implements IDisposable + @inject Radzen.DialogService RadzenDialogService +@inject NavigationManager NavigationManager ReqIF Viewer @@ -28,7 +31,7 @@ - + @@ -41,7 +44,7 @@ About - + @@ -60,6 +63,50 @@ @code { + /// + /// A value indicating whether the sidebar drawer is open on small screens + /// + private bool? isMobileSidebarOpen = false; + + /// + /// Subscribes to navigation, so the sidebar drawer closes when a link in it is followed on a small screen + /// + protected override void OnInitialized() + { + this.NavigationManager.LocationChanged += this.OnLocationChanged; + } + + /// + /// Keeps track of the sidebar drawer being opened or closed on small screens + /// + /// a value indicating whether the drawer is open + private void OnMobileSidebarOpenChanged(bool open) + { + this.isMobileSidebarOpen = open; + } + + /// + /// Closes the sidebar drawer on small screens after navigating + /// + /// the + /// the + private void OnLocationChanged(object sender, LocationChangedEventArgs e) + { + if (this.isMobileSidebarOpen == true) + { + this.isMobileSidebarOpen = false; + this.InvokeAsync(this.StateHasChanged); + } + } + + /// + /// Unsubscribes from navigation + /// + public void Dispose() + { + this.NavigationManager.LocationChanged -= this.OnLocationChanged; + } + /// /// Show the About dialog /// diff --git a/reqifviewer/Shared/SideMenu.razor b/reqifviewer/Shared/SideMenu.razor index c1eed8d..fc6f5b7 100644 --- a/reqifviewer/Shared/SideMenu.razor +++ b/reqifviewer/Shared/SideMenu.razor @@ -20,6 +20,8 @@ @using ReqIFSharp @using ReqIFSharp.Extensions.Services +@using ReqifViewer.ReqIFExtensions + @implements IDisposable @inject IReqIFLoaderService ReqIfLoaderService @@ -28,7 +30,8 @@ - + @* IsActive is false so only "Open a file" is highlighted on the home page. *@ +
ReqIF Viewer @@ -43,7 +46,8 @@ - + @* An empty Href never counts as active, so the home page is "/". *@ + Open a file @@ -79,8 +83,8 @@ @foreach (var specification in reqif.CoreContent.Specifications) { - - @(string.IsNullOrWhiteSpace(specification.LongName) ? specification.Identifier : specification.LongName) + + @specification.ExtractDisplayText() }