chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
+58
-12
@@ -109,7 +109,7 @@
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-instance">Instance</label>
|
||||
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance">
|
||||
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance" @bind:after="RecomputeVisibleRows">
|
||||
<option value="">All</option>
|
||||
@foreach (var name in DistinctInstances)
|
||||
{
|
||||
@@ -119,7 +119,7 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-kind">Kind</label>
|
||||
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind">
|
||||
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind" @bind:after="RecomputeVisibleRows">
|
||||
<option value="">All</option>
|
||||
<option value="@AlarmKind.Computed">Computed</option>
|
||||
<option value="@AlarmKind.NativeOpcUa">OPC UA</option>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-state">State</label>
|
||||
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState">
|
||||
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState" @bind:after="RecomputeVisibleRows">
|
||||
<option value="">All</option>
|
||||
<option value="@AlarmState.Active">Active</option>
|
||||
<option value="@AlarmState.Normal">Normal</option>
|
||||
@@ -136,7 +136,7 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="as-f-ack">Ack</label>
|
||||
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck">
|
||||
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck" @bind:after="RecomputeVisibleRows">
|
||||
<option value="">Any</option>
|
||||
<option value="unacked">Unacked</option>
|
||||
<option value="acked">Acked</option>
|
||||
@@ -146,12 +146,12 @@
|
||||
<label class="form-label small mb-1" for="as-f-sev">Min severity</label>
|
||||
<input id="as-f-sev" type="number" min="0" max="1000" step="50"
|
||||
class="form-control form-control-sm" style="width: 110px;"
|
||||
@bind="_filterMinSeverity" />
|
||||
@bind="_filterMinSeverity" @bind:after="RecomputeVisibleRows" />
|
||||
</div>
|
||||
<div class="col-auto flex-grow-1">
|
||||
<label class="form-label small mb-1" for="as-f-name">Name search</label>
|
||||
<input id="as-f-name" type="text" class="form-control form-control-sm"
|
||||
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" />
|
||||
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" @bind:after="RecomputeVisibleRows" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters">Clear</button>
|
||||
@@ -167,20 +167,30 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var filtered = FilteredRows().ToList();
|
||||
<div class="text-muted small mb-2">Showing @filtered.Count of @_rows.Count</div>
|
||||
<div class="text-muted small mb-2">Showing @_visibleRows.Count of @_rows.Count</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
@* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0",
|
||||
aria-sort reflects the active sort direction, and Enter/Space toggle the
|
||||
sort (Space preventDefault suppresses page scroll). The same pattern should
|
||||
be applied to the other custom grids in the app; that fleet-wide sweep is
|
||||
deferred and logged (arch-review UA6). *@
|
||||
<thead>
|
||||
<tr>
|
||||
<th role="button" @onclick='() => SortBy("instance")'>Instance @SortGlyph("instance")</th>
|
||||
<th role="button" @onclick='() => SortBy("name")'>Alarm @SortGlyph("name")</th>
|
||||
<th role="button" tabindex="0" aria-sort="@AriaSortFor("instance")"
|
||||
@onclick='() => SortBy("instance")'
|
||||
@onkeydown='e => OnHeaderKeyDown(e, "instance")' @onkeydown:preventDefault="_preventHeaderDefault">Instance @SortGlyph("instance")</th>
|
||||
<th role="button" tabindex="0" aria-sort="@AriaSortFor("name")"
|
||||
@onclick='() => SortBy("name")'
|
||||
@onkeydown='e => OnHeaderKeyDown(e, "name")' @onkeydown:preventDefault="_preventHeaderDefault">Alarm @SortGlyph("name")</th>
|
||||
<th>State / Kind</th>
|
||||
<th role="button" class="text-end" @onclick='() => SortBy("severity")'>Severity @SortGlyph("severity")</th>
|
||||
<th role="button" tabindex="0" class="text-end" aria-sort="@AriaSortFor("severity")"
|
||||
@onclick='() => SortBy("severity")'
|
||||
@onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in filtered)
|
||||
@foreach (var row in _visibleRows)
|
||||
{
|
||||
<tr data-test="alarm-summary-row">
|
||||
<td class="font-monospace small">@row.InstanceUniqueName</td>
|
||||
@@ -201,6 +211,12 @@
|
||||
private int? _selectedSiteId;
|
||||
|
||||
private IReadOnlyList<AlarmSummaryRow> _rows = Array.Empty<AlarmSummaryRow>();
|
||||
|
||||
// P4 (arch-review): the filtered+sorted view is memoized rather than recomputed
|
||||
// on every render. RecomputeVisibleRows() refreshes it whenever the inputs change
|
||||
// (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort).
|
||||
private IReadOnlyList<AlarmSummaryRow> _visibleRows = Array.Empty<AlarmSummaryRow>();
|
||||
|
||||
private IReadOnlyList<string> _notReporting = Array.Empty<string>();
|
||||
private AlarmRollup _rollup = new(0, 0, 0, new Dictionary<AlarmKind, int>());
|
||||
private bool _loading;
|
||||
@@ -266,6 +282,7 @@
|
||||
_rows = result.Alarms;
|
||||
_notReporting = result.NotReportingInstances;
|
||||
_rollup = AlarmSummaryService.ComputeRollup(_rows);
|
||||
RecomputeVisibleRows();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -308,6 +325,11 @@
|
||||
private IEnumerable<string> DistinctInstances =>
|
||||
_rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// P4: recompute the memoized filtered+sorted view from the current snapshot and
|
||||
// filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy —
|
||||
// NOT per render.
|
||||
private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList();
|
||||
|
||||
private IEnumerable<AlarmSummaryRow> FilteredRows()
|
||||
{
|
||||
IEnumerable<AlarmSummaryRow> q = _rows;
|
||||
@@ -366,11 +388,34 @@
|
||||
_sortKey = key;
|
||||
_sortDescending = key == "severity";
|
||||
}
|
||||
RecomputeVisibleRows();
|
||||
}
|
||||
|
||||
private string SortGlyph(string key) =>
|
||||
_sortKey != key ? "" : (_sortDescending ? "▼" : "▲");
|
||||
|
||||
// UA6: aria-sort token for a sortable header — ascending/descending on the active
|
||||
// column, "none" otherwise (per the WAI-ARIA aria-sort value set).
|
||||
private string AriaSortFor(string key) =>
|
||||
_sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending");
|
||||
|
||||
// UA6: whether the last header keydown should suppress the browser default. Bound to
|
||||
// @onkeydown:preventDefault (evaluated at render), so it takes effect on the next
|
||||
// Space keydown to stop the page scrolling when a header has keyboard focus.
|
||||
private bool _preventHeaderDefault;
|
||||
|
||||
// UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort,
|
||||
// mirroring the pointer click.
|
||||
private void OnHeaderKeyDown(KeyboardEventArgs e, string key)
|
||||
{
|
||||
var isSpace = e.Key is " " or "Spacebar";
|
||||
_preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone
|
||||
if (e.Key == "Enter" || isSpace)
|
||||
{
|
||||
SortBy(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
_filterInstance = "";
|
||||
@@ -379,6 +424,7 @@
|
||||
_filterAck = "";
|
||||
_filterMinSeverity = null;
|
||||
_filterName = "";
|
||||
RecomputeVisibleRows();
|
||||
}
|
||||
|
||||
private static string KindLabel(AlarmKind kind) => kind switch
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Operator Alarm Summary page — arch-review UA6
|
||||
/// (sortable-header accessibility). The sortable column headers must be keyboard
|
||||
/// operable: they carry <c>tabindex="0"</c>, expose their sort direction via
|
||||
/// <c>aria-sort</c> (<c>ascending</c>/<c>descending</c> on the active column,
|
||||
/// <c>none</c> otherwise), and toggle the sort when Enter is pressed on them —
|
||||
/// mirroring the existing pointer-click sort.
|
||||
/// </summary>
|
||||
public class AlarmSummaryRenderTests : BunitContext
|
||||
{
|
||||
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
public AlarmSummaryRenderTests()
|
||||
{
|
||||
// Two rows whose default sort (severity DESC) orders Zeta (900) before
|
||||
// Alpha (500); an ascending sort by instance name flips them.
|
||||
var rows = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
|
||||
new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
|
||||
};
|
||||
|
||||
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
|
||||
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
|
||||
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
|
||||
Services.AddSingleton(_summary);
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { new("Site 1", "site1") { Id = 1 } }));
|
||||
Services.AddSingleton(_siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
// Renders the page and drives the site picker so the alarm table appears.
|
||||
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
|
||||
{
|
||||
var cut = Render<AlarmSummaryPage>();
|
||||
cut.Find("[data-test='alarm-summary-site']").Change("1");
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.NotEmpty(cut.FindAll("tr[data-test='alarm-summary-row']")));
|
||||
return cut;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortableHeaders_CarryTabindexAndAriaSort()
|
||||
{
|
||||
var cut = RenderWithSiteSelected();
|
||||
|
||||
var headers = cut.FindAll("thead th");
|
||||
// 0=Instance (sortable), 1=Alarm (sortable), 2=State/Kind (not sortable), 3=Severity (sortable, active).
|
||||
var instance = headers[0];
|
||||
var stateKind = headers[2];
|
||||
var severity = headers[3];
|
||||
|
||||
Assert.Equal("0", instance.GetAttribute("tabindex"));
|
||||
Assert.Equal("0", severity.GetAttribute("tabindex"));
|
||||
|
||||
// Default sort is severity descending.
|
||||
Assert.Equal("descending", severity.GetAttribute("aria-sort"));
|
||||
Assert.Equal("none", instance.GetAttribute("aria-sort"));
|
||||
|
||||
// The non-sortable column is not keyboard-focusable and carries no aria-sort.
|
||||
Assert.Null(stateKind.GetAttribute("tabindex"));
|
||||
Assert.Null(stateKind.GetAttribute("aria-sort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterOnInstanceHeader_FlipsRowOrder()
|
||||
{
|
||||
var cut = RenderWithSiteSelected();
|
||||
|
||||
// Default severity-descending order → Zeta (900) first.
|
||||
Assert.Equal("Zeta", FirstRowInstance(cut));
|
||||
|
||||
// Enter on the Instance header sorts ascending by instance → Alpha first.
|
||||
cut.FindAll("thead th")[0].TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" });
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Alpha", FirstRowInstance(cut)));
|
||||
// aria-sort now reflects the active ascending instance column.
|
||||
Assert.Equal("ascending", cut.FindAll("thead th")[0].GetAttribute("aria-sort"));
|
||||
}
|
||||
|
||||
private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> cut) =>
|
||||
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
|
||||
.QuerySelector("td")!.TextContent.Trim();
|
||||
}
|
||||
Reference in New Issue
Block a user