feat(centralui): collapsible sidebar nav sections

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).
This commit is contained in:
Joseph Doherty
2026-05-22 07:36:57 -04:00
parent d4abacc0d8
commit 86ee7bd1a8
11 changed files with 511 additions and 95 deletions

View File

@@ -1,4 +1,10 @@
@using System.Linq
@using ScadaLink.Security @using ScadaLink.Security
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
<nav class="sidebar d-flex flex-column"> <nav class="sidebar d-flex flex-column">
<div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div> <div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div>
@@ -14,7 +20,9 @@
@* Admin section — Admin role only *@ @* Admin section — Admin role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext"> <Authorized Context="adminContext">
<div role="presentation" class="nav-section-header">Admin</div> <NavSection Title="Admin"
Expanded="@_expanded.Contains("admin")"
OnToggle="@(() => ToggleAsync("admin"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink> <NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
</li> </li>
@@ -24,13 +32,16 @@
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink> <NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
</li> </li>
</NavSection>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Design section — Design role *@ @* Design section — Design role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext"> <Authorized Context="designContext">
<div role="presentation" class="nav-section-header">Design</div> <NavSection Title="Design"
Expanded="@_expanded.Contains("design")"
OnToggle="@(() => ToggleAsync("design"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/design/templates">Templates</NavLink> <NavLink class="nav-link" href="/design/templates">Templates</NavLink>
</li> </li>
@@ -43,13 +54,16 @@
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink> <NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li> </li>
</NavSection>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Deployment section — Deployment role *@ @* Deployment section — Deployment role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext"> <Authorized Context="deploymentContext">
<div role="presentation" class="nav-section-header">Deployment</div> <NavSection Title="Deployment"
Expanded="@_expanded.Contains("deployment")"
OnToggle="@(() => ToggleAsync("deployment"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink> <NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
</li> </li>
@@ -59,13 +73,16 @@
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink> <NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
</li> </li>
</NavSection>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Notifications — mixed-role section; each item gated by its own policy. @* Notifications — mixed-role section; each item gated by its own policy.
The header is ungated: every authenticated user holds at least one of The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@ Admin/Design/Deployment, so it always has a visible child. *@
<div role="presentation" class="nav-section-header">Notifications</div> <NavSection Title="Notifications"
Expanded="@_expanded.Contains("notifications")"
OnToggle="@(() => ToggleAsync("notifications"))">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext"> <Authorized Context="notifAdminContext">
<li class="nav-item"> <li class="nav-item">
@@ -90,23 +107,31 @@
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</NavSection>
@* Site Calls — Site Call Audit (#22). Deployment-role only, @* Site Calls — Site Call Audit (#22). Deployment-role only,
matching the Notification Report page's gate; the section matching the Notification Report page's gate; the whole
header sits inside the policy block so a non-Deployment section sits inside the policy block so a non-Deployment
user does not see the heading. *@ user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext"> <Authorized Context="siteCallsContext">
<div role="presentation" class="nav-section-header">Site Calls</div> <NavSection Title="Site Calls"
Expanded="@_expanded.Contains("sitecalls")"
OnToggle="@(() => ToggleAsync("sitecalls"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink> <NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
</li> </li>
</NavSection>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Monitoring — Health Dashboard is all-roles; Event Logs and @* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI). *@ Parked Messages are Deployment-role only (Component-CentralUI).
<div role="presentation" class="nav-section-header">Monitoring</div> The section is ungated because Health Dashboard is always
a visible child. *@
<NavSection Title="Monitoring"
Expanded="@_expanded.Contains("monitoring")"
OnToggle="@(() => ToggleAsync("monitoring"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink> <NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li> </li>
@@ -120,24 +145,27 @@
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</NavSection>
@* Audit — gated on the OperationalAudit policy (#23 M7-T15 @* Audit — gated on the OperationalAudit policy (#23 M7-T15
/ Bundle G). Hosts the new Audit Log page (#23 M7) and / Bundle G). Hosts the Audit Log page (#23 M7) and the
the renamed Configuration Audit Log (IAuditService Configuration Audit Log (IAuditService config-change
config-change viewer). Both items share the same gate, viewer). The whole section sits inside the policy block:
so the section header sits inside the same policy block:
a non-audit user does not even see the heading. a non-audit user does not even see the heading.
OperationalAudit is satisfied by the Admin, Audit, and OperationalAudit is satisfied by the Admin, Audit, and
AuditReadOnly roles. *@ AuditReadOnly roles. *@
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit"> <AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
<Authorized Context="auditContext"> <Authorized Context="auditContext">
<div role="presentation" class="nav-section-header">Audit</div> <NavSection Title="Audit"
Expanded="@_expanded.Contains("audit")"
OnToggle="@(() => ToggleAsync("audit"))">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink> <NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink> <NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
</li> </li>
</NavSection>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</Authorized> </Authorized>
@@ -162,3 +190,126 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</nav> </nav>
@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<string> _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<string>("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;
}
}

View File

@@ -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 <li> and the item
<li>s (ChildContent) render as siblings inside NavMenu's <ul>. *@
<li class="nav-item">
<button type="button"
class="nav-section-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<i class="bi @(Expanded ? "bi-chevron-down" : "bi-chevron-right")" aria-hidden="true"></i>
<span>@Title</span>
</button>
</li>
@if (Expanded)
{
@ChildContent
}
@code {
/// <summary>Section label shown in the header (e.g. "Deployment").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its items rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the header button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's nav items, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}

View File

@@ -42,7 +42,17 @@
padding-left: calc(1rem - 3px); 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); color: var(--ink-faint);
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
@@ -52,6 +62,15 @@
margin-top: 0.5rem; 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 { .sidebar .brand {
color: var(--ink); color: var(--ink);
font-size: 1.1rem; font-size: 1.1rem;

View File

@@ -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";
}
};

View File

@@ -77,6 +77,7 @@
}); });
</script> </script>
<script src="/js/treeview-storage.js"></script> <script src="/js/treeview-storage.js"></script>
<script src="_content/ScadaLink.CentralUI/js/nav-state.js"></script>
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script> <script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script> <script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>

View File

@@ -0,0 +1,88 @@
using Microsoft.Playwright;
namespace ScadaLink.CentralUI.PlaywrightTests;
/// <summary>
/// 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 <c>scadabridge_nav</c> cookie across a full page reload, and navigating
/// into a section auto-expands it.
/// </summary>
[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);
}

View File

@@ -77,6 +77,8 @@ public class NavigationTests
private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath) 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 page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
await PlaywrightFixture.WaitForPathAsync(page, expectedPath); await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
Assert.Contains(expectedPath, page.Url); Assert.Contains(expectedPath, page.Url);

View File

@@ -105,6 +105,28 @@ public class PlaywrightFixture : IAsyncLifetime
: $"() => window.location.pathname.includes('{path}')"; : $"() => window.location.pathname.includes('{path}')";
await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs }); await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs });
} }
/// <summary>
/// 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.
/// </summary>
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")] [CollectionDefinition("Playwright")]

View File

@@ -220,6 +220,9 @@ public class RoleNavigationTests
private static async Task AssertNavLinkVisible(IPage page, string linkText) 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 locator = page.Locator($"nav a:has-text('{linkText}')");
var count = await locator.CountAsync(); var count = await locator.CountAsync();
Assert.True(count > 0, $"Expected nav link '{linkText}' to be visible, but it was not found"); Assert.True(count > 0, $"Expected nav link '{linkText}' to be visible, but it was not found");

View File

@@ -12,14 +12,24 @@ namespace ScadaLink.CentralUI.Tests.Layout;
/// <summary> /// <summary>
/// bUnit rendering tests for the sidebar <see cref="NavMenu"/>. They verify the /// bUnit rendering tests for the sidebar <see cref="NavMenu"/>. They verify the
/// new Notifications section: its items are gated per-policy, and the old /// collapsible section behaviour (sections collapsed by default, a toggle
/// <c>/admin/smtp</c> and <c>/monitoring/notification-outbox</c> routes are gone. /// reveals a section's items and persists state to a cookie) and that the
/// The <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which /// Notifications section's items are gated per-policy. The
/// <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which
/// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> ("Role"), /// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> ("Role"),
/// so the test principal carries claims of that exact type. /// so the test principal carries claims of that exact type.
/// </summary> /// </summary>
public class NavMenuTests : BunitContext 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;
}
/// <summary> /// <summary>
/// Renders <see cref="NavMenu"/> under a principal holding the given roles. /// Renders <see cref="NavMenu"/> under a principal holding the given roles.
/// <see cref="NavMenu"/>'s top-level <c>AuthorizeView</c> requires the /// <see cref="NavMenu"/>'s top-level <c>AuthorizeView</c> requires the
@@ -52,10 +62,62 @@ public class NavMenuTests : BunitContext
return host.FindComponent<NavMenu>(); return host.FindComponent<NavMenu>();
} }
/// <summary>
/// Clicks the collapsible section header whose title matches, expanding it.
/// </summary>
private static void ExpandSection(IRenderedComponent<NavMenu> 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] [Fact]
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
{ {
var cut = RenderWithRoles("Admin", "Design", "Deployment"); var cut = RenderWithRoles("Admin", "Design", "Deployment");
ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -71,6 +133,7 @@ public class NavMenuTests : BunitContext
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
{ {
var cut = RenderWithRoles("Admin"); var cut = RenderWithRoles("Admin");
ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {

View File

@@ -103,6 +103,18 @@ public class AuditLogPageScaffoldTests : BunitContext
return host.FindComponent<NavMenu>(); return host.FindComponent<NavMenu>();
} }
/// <summary>
/// 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.
/// </summary>
private static void ExpandNavSection(IRenderedComponent<NavMenu> cut, string title)
{
var toggle = cut.FindAll("button.nav-section-toggle")
.Single(b => b.TextContent.Contains(title, StringComparison.Ordinal));
toggle.Click();
}
[Fact] [Fact]
public void AuditLogPage_Renders_PageHeading() public void AuditLogPage_Renders_PageHeading()
{ {
@@ -121,6 +133,7 @@ public class AuditLogPageScaffoldTests : BunitContext
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link() public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
{ {
var cut = RenderNavMenu("Admin", "Design", "Deployment"); var cut = RenderNavMenu("Admin", "Design", "Deployment");
ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -133,6 +146,7 @@ public class AuditLogPageScaffoldTests : BunitContext
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
{ {
var cut = RenderNavMenu("Admin", "Design", "Deployment"); var cut = RenderNavMenu("Admin", "Design", "Deployment");
ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {