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(() =>
{