fix(centralui): apply status/stuck query-string filters on the Site Calls page

This commit is contained in:
Joseph Doherty
2026-05-21 05:08:50 -04:00
parent 44f1ee372a
commit b3b02a8cb6
2 changed files with 138 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
@@ -26,11 +28,32 @@ namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
/// </para>
///
/// <para>
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
// filter when it matches one of these (case-insensitively); anything else is
// dropped so a hand-crafted bad URL still renders the page unfiltered.
private static readonly string[] ValidStatuses =
{
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
};
private ToastNotification _toast = default!;
private List<Site> _sites = new();
@@ -77,9 +100,51 @@ public partial class SiteCallsReport
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
}
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
// grid load is already filtered (and the filter card controls reflect it).
ApplyQueryStringFilters();
await RefreshAll();
}
/// <summary>
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. <c>?status=&lt;status&gt;</c> seeds <see cref="_statusFilter"/> when it
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
/// is silently dropped, leaving the filter empty (the no-param behaviour).
/// </summary>
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("status", out var statusValues))
{
var v = statusValues.ToString();
// Round-trip the dropdown's own option strings (the KPI tile emits the
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
// <select> binds. An unrecognised value leaves the filter unset.
var match = ValidStatuses.FirstOrDefault(
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
_statusFilter = match;
}
}
if (query.TryGetValue("stuck", out var stuckValues)
&& bool.TryParse(stuckValues.ToString(), out var stuck))
{
_stuckOnly = stuck;
}
}
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
private async Task RefreshAll()
{

View File

@@ -1,7 +1,9 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -411,6 +413,77 @@ public class SiteCallsReportPageTests : BunitContext
});
}
// ─────────────────────────────────────────────────────────────────────────
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
// params must seed the filter BEFORE the first query so the initial grid load
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
// The Parked KPI tile emits ?status=Parked — set the URI before render.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=Parked");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first (and only) query the page issues carries the Parked
// status filter — the grid load is pre-filtered, not unfiltered.
Assert.Single(_queryRequests);
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
// The Status <select> control reflects the seeded value so the
// operator sees the filter and can Clear it.
var statusSelect = cut.Find("#sc-status");
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
});
}
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
// The Stuck KPI tile emits ?stuck=true.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=true");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first query carries StuckOnly = true.
Assert.Single(_queryRequests);
Assert.True(_queryRequests[0].StuckOnly);
// The "Stuck only" checkbox is checked.
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.True(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
// No drill-in params — the page loads exactly as before: an unfiltered
// query and no status/stuck filter set on the controls.
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(_queryRequests);
Assert.Null(_queryRequests[0].StatusFilter);
Assert.False(_queryRequests[0].StuckOnly);
var statusSelect = cut.Find("#sc-status");
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.False(stuckCheckbox.HasAttribute("checked"));
});
}
protected override void Dispose(bool disposing)
{
if (disposing)