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:
@@ -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
|
||||
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand"><span class="mark">▮</span> ScadaBridge</div>
|
||||
@@ -14,7 +20,9 @@
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<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">
|
||||
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
@@ -24,13 +32,16 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<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">
|
||||
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||
</li>
|
||||
@@ -43,13 +54,16 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<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">
|
||||
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||
</li>
|
||||
@@ -59,13 +73,16 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* 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. *@
|
||||
<div role="presentation" class="nav-section-header">Notifications</div>
|
||||
<NavSection Title="Notifications"
|
||||
Expanded="@_expanded.Contains("notifications")"
|
||||
OnToggle="@(() => ToggleAsync("notifications"))">
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="notifAdminContext">
|
||||
<li class="nav-item">
|
||||
@@ -90,23 +107,31 @@
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavSection>
|
||||
|
||||
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||
matching the Notification Report page's gate; the section
|
||||
header sits inside the policy block so a non-Deployment
|
||||
matching the Notification Report page's gate; the whole
|
||||
section sits inside the policy block so a non-Deployment
|
||||
user does not see the heading. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<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">
|
||||
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||
Parked Messages are Deployment-role only (Component-CentralUI). *@
|
||||
<div role="presentation" class="nav-section-header">Monitoring</div>
|
||||
Parked Messages are Deployment-role only (Component-CentralUI).
|
||||
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">
|
||||
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
@@ -120,24 +145,27 @@
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavSection>
|
||||
|
||||
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
||||
/ Bundle G). Hosts the new Audit Log page (#23 M7) and
|
||||
the renamed Configuration Audit Log (IAuditService
|
||||
config-change viewer). Both items share the same gate,
|
||||
so the section header sits inside the same policy block:
|
||||
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
|
||||
Configuration Audit Log (IAuditService config-change
|
||||
viewer). The whole section sits inside the policy block:
|
||||
a non-audit user does not even see the heading.
|
||||
OperationalAudit is satisfied by the Admin, Audit, and
|
||||
AuditReadOnly roles. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||
<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">
|
||||
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</Authorized>
|
||||
@@ -162,3 +190,126 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
35
src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
Normal file
35
src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
18
src/ScadaLink.CentralUI/wwwroot/js/nav-state.js
Normal file
18
src/ScadaLink.CentralUI/wwwroot/js/nav-state.js
Normal 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";
|
||||
}
|
||||
};
|
||||
@@ -77,6 +77,7 @@
|
||||
});
|
||||
</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/audit-grid.js"></script>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -105,6 +105,28 @@ public class PlaywrightFixture : IAsyncLifetime
|
||||
: $"() => window.location.pathname.includes('{path}')";
|
||||
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")]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -12,14 +12,24 @@ namespace ScadaLink.CentralUI.Tests.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the sidebar <see cref="NavMenu"/>. They verify the
|
||||
/// new Notifications section: its items are gated per-policy, and the old
|
||||
/// <c>/admin/smtp</c> and <c>/monitoring/notification-outbox</c> routes are gone.
|
||||
/// The <c>AuthorizeView Policy=...</c> 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
|
||||
/// <c>AuthorizeView Policy=...</c> blocks evaluate the real policies, which
|
||||
/// require a claim of type <see cref="JwtTokenService.RoleClaimType"/> ("Role"),
|
||||
/// so the test principal carries claims of that exact type.
|
||||
/// </summary>
|
||||
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>
|
||||
/// Renders <see cref="NavMenu"/> under a principal holding the given roles.
|
||||
/// <see cref="NavMenu"/>'s top-level <c>AuthorizeView</c> requires the
|
||||
@@ -52,10 +62,62 @@ public class NavMenuTests : BunitContext
|
||||
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]
|
||||
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(() =>
|
||||
{
|
||||
|
||||
@@ -103,6 +103,18 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
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]
|
||||
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(() =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user