From 86ee7bd1a81d130304209a52d405563b208af294 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 07:36:57 -0400 Subject: [PATCH] feat(centralui): collapsible sidebar nav sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the seven sidebar section groups (Admin, Design, Deployment, Notifications, Site Calls, Monitoring, Audit) collapsible. New NavSection component renders a header toggle button (chevron) and reveals its items only while expanded; NavMenu owns the expanded-section set. Behaviour: sections are collapsed by default; state persists in the `scadabridge_nav` cookie (written/read via the new nav-state.js JS interop, mirroring treeview-storage.js) so it survives reloads and reconnects; navigating into a section auto-expands it and remembers it. The Dashboard item stays sectionless and always visible. Tests: NavMenu bUnit tests expand sections before asserting items and add collapsed-by-default / toggle / cookie-persistence cases; Playwright nav tests expand sections before clicking links; new NavCollapseTests covers the feature E2E. Build 0 warnings; bUnit 545 passed; Playwright nav suite green (the unrelated AuditGridColumnTests resize-reload case remains pre-existing flaky — an un-awaited save race in that test). --- .../Components/Layout/NavMenu.razor | 333 +++++++++++++----- .../Components/Layout/NavSection.razor | 35 ++ src/ScadaLink.CentralUI/wwwroot/css/site.css | 21 +- .../wwwroot/js/nav-state.js | 18 + src/ScadaLink.Host/Components/App.razor | 1 + .../NavCollapseTests.cs | 88 +++++ .../NavigationTests.cs | 2 + .../PlaywrightFixture.cs | 22 ++ .../RoleNavigationTests.cs | 3 + .../Layout/NavMenuTests.cs | 69 +++- .../Pages/AuditLogPageScaffoldTests.cs | 14 + 11 files changed, 511 insertions(+), 95 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Layout/NavSection.razor create mode 100644 src/ScadaLink.CentralUI/wwwroot/js/nav-state.js create mode 100644 tests/ScadaLink.CentralUI.PlaywrightTests/NavCollapseTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index ffe1024..d35b304 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -1,4 +1,10 @@ +@using System.Linq @using ScadaLink.Security +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.JSInterop +@implements IDisposable +@inject NavigationManager Navigation +@inject IJSRuntime JS + +@code { + // Expanded-section state persists in the "scadabridge_nav" cookie, written + // by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a + // comma-separated list of section ids. + + // Every collapsible section id. Also the allow-list for parsing the cookie. + private static readonly string[] SectionIds = + { "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" }; + + // The currently-expanded sections. Populated from the cookie on first + // render; mutated by ToggleAsync and by navigating into a section. + private readonly HashSet _expanded = new(StringComparer.Ordinal); + + protected override void OnInitialized() + { + Navigation.LocationChanged += OnLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + // Hydrate from the cookie. Until this completes the sidebar paints + // collapsed (the "collapsed by default" state) — matching how TreeView + // hydrates its expand state in OnAfterRenderAsync(firstRender). + string saved; + try + { + saved = await JS.InvokeAsync("navState.get") ?? string.Empty; + } + catch (JSDisconnectedException) + { + return; + } + + foreach (var id in saved.Split( + ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Array.IndexOf(SectionIds, id) >= 0) + { + _expanded.Add(id); + } + } + + // The section of the page we loaded on is always expanded. + if (EnsureCurrentSectionExpanded()) + { + await PersistAsync(); + } + + StateHasChanged(); + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + // Navigating into a collapsed section expands it (and remembers it). + if (EnsureCurrentSectionExpanded()) + { + _ = PersistAsync(); + _ = InvokeAsync(StateHasChanged); + } + } + + private async Task ToggleAsync(string id) + { + if (!_expanded.Remove(id)) + { + _expanded.Add(id); + } + + await PersistAsync(); + } + + // Adds the current page's section to _expanded; returns true if it changed. + private bool EnsureCurrentSectionExpanded() + { + var section = CurrentSection(); + return section is not null && _expanded.Add(section); + } + + // Maps the current URL's first path segment to a section id, or null for + // sectionless pages (Dashboard, Login). + private string? CurrentSection() + { + var relative = Navigation.ToBaseRelativePath(Navigation.Uri); + var firstSegment = relative.Split('?', '#')[0] + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + return firstSegment switch + { + "admin" => "admin", + "design" => "design", + "deployment" => "deployment", + "notifications" => "notifications", + "site-calls" => "sitecalls", + "monitoring" => "monitoring", + "audit" => "audit", + _ => null, + }; + } + + private async Task PersistAsync() + { + try + { + await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded)); + } + catch (JSDisconnectedException) + { + // The circuit is gone — nothing to persist to. + } + } + + public void Dispose() + { + Navigation.LocationChanged -= OnLocationChanged; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor b/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor new file mode 100644 index 0000000..e183258 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor @@ -0,0 +1,35 @@ +@* A collapsible sidebar nav section: an uppercase-eyebrow header button that + toggles the visibility of its child nav items. The header
  • and the item +
  • s (ChildContent) render as siblings inside NavMenu's
      . *@ + + +@if (Expanded) +{ + @ChildContent +} + +@code { + /// Section label shown in the header (e.g. "Deployment"). + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// Whether the section is expanded — its items rendered. + [Parameter] + public bool Expanded { get; set; } + + /// Raised when the header button is clicked. + [Parameter] + public EventCallback OnToggle { get; set; } + + /// The section's nav items, rendered only while expanded. + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/ScadaLink.CentralUI/wwwroot/css/site.css b/src/ScadaLink.CentralUI/wwwroot/css/site.css index 857eabc..bc90585 100644 --- a/src/ScadaLink.CentralUI/wwwroot/css/site.css +++ b/src/ScadaLink.CentralUI/wwwroot/css/site.css @@ -42,7 +42,17 @@ padding-left: calc(1rem - 3px); } -.sidebar .nav-section-header { +/* Collapsible section header — a full-width button styled as an uppercase + eyebrow with a leading expand/collapse chevron. */ +.sidebar .nav-section-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + background: none; + border: 0; + cursor: pointer; + text-align: left; color: var(--ink-faint); font-size: 0.7rem; font-weight: 600; @@ -52,6 +62,15 @@ margin-top: 0.5rem; } +.sidebar .nav-section-toggle:hover { + color: var(--ink); +} + +.sidebar .nav-section-toggle .bi { + font-size: 0.8rem; + line-height: 1; +} + .sidebar .brand { color: var(--ink); font-size: 1.1rem; diff --git a/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js b/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js new file mode 100644 index 0000000..64006c2 --- /dev/null +++ b/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js @@ -0,0 +1,18 @@ +// Sidebar nav collapse state — persisted in the `scadabridge_nav` cookie so it +// survives full page reloads and reconnects. Invoked from NavMenu.razor via +// JS interop (window.navState.get / .set), mirroring window.treeviewStorage. +window.navState = { + // Returns the raw cookie value (comma-separated expanded section ids), or + // an empty string when the cookie is absent. + get: function () { + const match = document.cookie.match(/(?:^|;\s*)scadabridge_nav=([^;]*)/); + return match ? decodeURIComponent(match[1]) : ""; + }, + // Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly + // (JS must write it) and not sensitive. + set: function (value) { + const oneYearSeconds = 60 * 60 * 24 * 365; + document.cookie = "scadabridge_nav=" + encodeURIComponent(value) + + ";path=/;max-age=" + oneYearSeconds + ";samesite=lax"; + } +}; diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index 14bb98f..32aee40 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -77,6 +77,7 @@ }); + diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/NavCollapseTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/NavCollapseTests.cs new file mode 100644 index 0000000..27c7367 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/NavCollapseTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Playwright; + +namespace ScadaLink.CentralUI.PlaywrightTests; + +/// +/// E2E tests for the collapsible sidebar nav sections: sections are collapsed +/// by default, a header toggle reveals a section's items, the state persists in +/// the scadabridge_nav cookie across a full page reload, and navigating +/// into a section auto-expands it. +/// +[Collection("Playwright")] +public class NavCollapseTests +{ + private readonly PlaywrightFixture _fixture; + + public NavCollapseTests(PlaywrightFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Sections_AreCollapsedByDefault_AfterLogin() + { + var page = await _fixture.NewAuthenticatedPageAsync(); + + // The dashboard is sectionless, so no section is auto-expanded and the + // cookie is empty on a fresh context — every section toggle is collapsed. + await Expect(page.Locator("button.nav-section-toggle[aria-expanded='true']")) + .ToHaveCountAsync(0); + // A sectioned link is therefore absent from the DOM. + Assert.Equal(0, await page.Locator("nav a:has-text('Topology')").CountAsync()); + } + + [Fact] + public async Task ClickingSectionHeader_RevealsItsItems() + { + var page = await _fixture.NewAuthenticatedPageAsync(); + var toggle = page.Locator("button.nav-section-toggle:has-text('Deployment')"); + + Assert.Equal(0, await page.Locator("nav a:has-text('Topology')").CountAsync()); + + await toggle.ClickAsync(); + + await Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true"); + await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" })) + .ToBeVisibleAsync(); + } + + [Fact] + public async Task CollapseState_SurvivesPageReload() + { + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.Locator("button.nav-section-toggle:has-text('Deployment')").ClickAsync(); + await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" })) + .ToBeVisibleAsync(); + + await page.ReloadAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The scadabridge_nav cookie restored the expanded Deployment section. + await Expect(page.Locator("button.nav-section-toggle:has-text('Deployment')")) + .ToHaveAttributeAsync("aria-expanded", "true"); + await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Topology" })) + .ToBeVisibleAsync(); + } + + [Fact] + public async Task NavigatingIntoCollapsedSection_AutoExpandsIt() + { + var page = await _fixture.NewAuthenticatedPageAsync(); + var auditToggle = page.Locator("button.nav-section-toggle:has-text('Audit')"); + + // The Audit section starts collapsed. + await Expect(auditToggle).ToHaveAttributeAsync("aria-expanded", "false"); + + // Navigate into the Audit section via an in-page link (SPA navigation, + // which raises NavigationManager.LocationChanged) — the Configuration + // Audit Log quick-action card on the dashboard. + await page.Locator("a[href='/audit/configuration']").First.ClickAsync(); + await PlaywrightFixture.WaitForPathAsync(page, "/audit/configuration"); + + // The Audit nav section auto-expanded on arrival. + await Expect(auditToggle).ToHaveAttributeAsync("aria-expanded", "true"); + } + + private static ILocatorAssertions Expect(ILocator locator) => + Assertions.Expect(locator); +} diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs index 3955812..b4338d8 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs @@ -77,6 +77,8 @@ public class NavigationTests private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath) { + // Sections are collapsed by default — open them so the link is in the DOM. + await PlaywrightFixture.ExpandAllNavSectionsAsync(page); await page.Locator($"nav a:has-text('{linkText}')").ClickAsync(); await PlaywrightFixture.WaitForPathAsync(page, expectedPath); Assert.Contains(expectedPath, page.Url); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs index 995462e..73d7d34 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs @@ -105,6 +105,28 @@ public class PlaywrightFixture : IAsyncLifetime : $"() => window.location.pathname.includes('{path}')"; await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs }); } + + /// + /// Expand every collapsed sidebar nav section. Nav sections are collapsed by + /// default, so a section's links are not in the DOM until it is expanded. + /// Call this after authenticating, before interacting with sectioned nav links. + /// + public static async Task ExpandAllNavSectionsAsync(IPage page) + { + var toggles = page.Locator("button.nav-section-toggle"); + int count = await toggles.CountAsync(); + for (int i = 0; i < count; i++) + { + var toggle = toggles.Nth(i); + if (await toggle.GetAttributeAsync("aria-expanded") == "false") + { + await toggle.ClickAsync(); + // Wait for the toggle's own state to flip so the Blazor + // re-render has landed before moving to the next section. + await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true"); + } + } + } } [CollectionDefinition("Playwright")] diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs index 508ec56..f14e772 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs @@ -220,6 +220,9 @@ public class RoleNavigationTests private static async Task AssertNavLinkVisible(IPage page, string linkText) { + // Sections are collapsed by default — expand them so a present link is + // in the DOM. Idempotent: already-expanded sections are skipped. + await PlaywrightFixture.ExpandAllNavSectionsAsync(page); var locator = page.Locator($"nav a:has-text('{linkText}')"); var count = await locator.CountAsync(); Assert.True(count > 0, $"Expected nav link '{linkText}' to be visible, but it was not found"); diff --git a/tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs b/tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs index c416456..ed9538e 100644 --- a/tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs @@ -12,14 +12,24 @@ namespace ScadaLink.CentralUI.Tests.Layout; /// /// bUnit rendering tests for the sidebar . They verify the -/// new Notifications section: its items are gated per-policy, and the old -/// /admin/smtp and /monitoring/notification-outbox routes are gone. -/// The AuthorizeView Policy=... blocks evaluate the real policies, which +/// collapsible section behaviour (sections collapsed by default, a toggle +/// reveals a section's items and persists state to a cookie) and that the +/// Notifications section's items are gated per-policy. The +/// AuthorizeView Policy=... blocks evaluate the real policies, which /// require a claim of type ("Role"), /// so the test principal carries claims of that exact type. /// public class NavMenuTests : BunitContext { + public NavMenuTests() + { + // NavMenu reads the nav-collapse cookie via the navState.get JS interop + // call in OnAfterRenderAsync and writes it via navState.set on toggle. + // Loose mode lets navState.get no-op (returns null) so the sidebar + // renders collapsed, and still records navState.set invocations. + JSInterop.Mode = JSRuntimeMode.Loose; + } + /// /// Renders under a principal holding the given roles. /// 's top-level AuthorizeView requires the @@ -52,10 +62,62 @@ public class NavMenuTests : BunitContext return host.FindComponent(); } + /// + /// Clicks the collapsible section header whose title matches, expanding it. + /// + private static void ExpandSection(IRenderedComponent cut, string title) + { + var toggle = cut.FindAll("button.nav-section-toggle") + .Single(b => b.TextContent.Contains(title, StringComparison.Ordinal)); + toggle.Click(); + } + + [Fact] + public void Sections_AreCollapsedByDefault() + { + var cut = RenderWithRoles("Admin", "Design", "Deployment"); + + cut.WaitForAssertion(() => + { + // Section headers render unconditionally... + Assert.Contains(">Notifications<", cut.Markup); + Assert.Contains(">Deployment<", cut.Markup); + // ...but their items stay out of the DOM until the section opens. + Assert.DoesNotContain("/notifications/smtp", cut.Markup); + Assert.DoesNotContain("/deployment/topology", cut.Markup); + }); + } + + [Fact] + public void TogglingSection_RevealsItsItems() + { + var cut = RenderWithRoles("Deployment"); + Assert.DoesNotContain("/deployment/topology", cut.Markup); + + ExpandSection(cut, "Deployment"); + + Assert.Contains("/deployment/topology", cut.Markup); + Assert.Contains("/deployment/deployments", cut.Markup); + Assert.Contains("/deployment/debug-view", cut.Markup); + } + + [Fact] + public void TogglingSection_PersistsStateToCookie() + { + var cut = RenderWithRoles("Deployment"); + + ExpandSection(cut, "Deployment"); + + // Expanding wrote the cookie through the navState.set JS interop call. + var invocation = JSInterop.Invocations.Last(i => i.Identifier == "navState.set"); + Assert.Equal("deployment", invocation.Arguments[0]); + } + [Fact] public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() { var cut = RenderWithRoles("Admin", "Design", "Deployment"); + ExpandSection(cut, "Notifications"); cut.WaitForAssertion(() => { @@ -71,6 +133,7 @@ public class NavMenuTests : BunitContext public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() { var cut = RenderWithRoles("Admin"); + ExpandSection(cut, "Notifications"); cut.WaitForAssertion(() => { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 30acd90..a950608 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -103,6 +103,18 @@ public class AuditLogPageScaffoldTests : BunitContext return host.FindComponent(); } + /// + /// Clicks the collapsible section header whose title matches, expanding it. + /// Nav sections are collapsed by default, so a section's items are only in + /// the DOM once expanded. + /// + private static void ExpandNavSection(IRenderedComponent cut, string title) + { + var toggle = cut.FindAll("button.nav-section-toggle") + .Single(b => b.TextContent.Contains(title, StringComparison.Ordinal)); + toggle.Click(); + } + [Fact] public void AuditLogPage_Renders_PageHeading() { @@ -121,6 +133,7 @@ public class AuditLogPageScaffoldTests : BunitContext public void NavMenu_Contains_AuditGroup_With_AuditLog_Link() { var cut = RenderNavMenu("Admin", "Design", "Deployment"); + ExpandNavSection(cut, "Audit"); cut.WaitForAssertion(() => { @@ -133,6 +146,7 @@ public class AuditLogPageScaffoldTests : BunitContext public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() { var cut = RenderNavMenu("Admin", "Design", "Deployment"); + ExpandNavSection(cut, "Audit"); cut.WaitForAssertion(() => {