Merge branch 'feature/collapsible-nav-sections': collapsible sidebar nav sections
This commit is contained in:
@@ -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">▮</span> ScadaBridge</div>
|
<div class="brand"><span class="mark">▮</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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);
|
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;
|
||||||
|
|||||||
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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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)
|
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);
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user