From 33f9c97030a3bd9e6ec64b003be8c287bb7bed64 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 11:04:39 -0400 Subject: [PATCH] feat(central-ui): Site Calls page accepts ?correlationId= and opens the operation's detail Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Pages/SiteCalls/SiteCallsReport.razor | 14 +++ .../Pages/SiteCalls/SiteCallsReport.razor.cs | 39 +++++- .../Pages/QueryStringDrillInTests.cs | 45 +++++++ .../Pages/SiteCallsReportPageTests.cs | 114 ++++++++++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor index 05a1208c..dddd2f07 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -90,6 +90,20 @@ + @if (_operationIdFilter is { } operationId) + { + @* Read-only pill for the ?correlationId= deep link (Flow Debugger / + Audit Log). Its × drops just this filter and re-queries; the + Clear button drops it along with everything else. *@ +
+ Operation @ShortId(operationId) + +
+ }
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs index 72d34fdc..27dc2c44 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -102,6 +102,11 @@ public partial class SiteCallsReport private DateTime? _fromFilter; private DateTime? _toFilter; + // Exact-match on one operation, seeded only from the ?correlationId= deep + // link (Flow Debugger / Audit Log). Rendered as a read-only pill rather than + // an editable control — there is no sensible way to type one in. + private Guid? _operationIdFilter; + // ── Trends ────────────────────────────────────────────────────────────── // Collapsible KPI-history charts for the SiteCallAudit / Global series. The // section is collapsed on init and the series load lazily on first expand @@ -162,14 +167,26 @@ public partial class SiteCallsReport ApplyQueryStringFilters(); await RefreshAll(); + + // The ?correlationId= deep link lands on the operation itself: when the + // filtered first page is exactly that one row, open its detail so the + // operator is not left to click the row they navigated for. + if (_operationIdFilter is { } op + && _siteCalls is { Count: 1 } + && _siteCalls[0].TrackedOperationId == op) + { + await ShowDetail(_siteCalls[0]); + } } /// /// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query /// string. ?status=<status> seeds when it /// matches a known status (case-insensitive); ?stuck=true seeds - /// . Lax parsing — an absent, blank, or unrecognised value - /// is silently dropped, leaving the filter empty (the no-param behaviour). + /// ; ?correlationId=<guid> seeds + /// (the Flow Debugger / Audit Log deep link onto + /// one operation). Lax parsing — an absent, blank, or unrecognised value is + /// silently dropped, leaving the filter empty (the no-param behaviour). /// private void ApplyQueryStringFilters() { @@ -200,6 +217,12 @@ public partial class SiteCallsReport { _stuckOnly = stuck; } + + if (query.TryGetValue("correlationId", out var correlationValues) + && Guid.TryParse(correlationValues.ToString(), out var operationId)) + { + _operationIdFilter = operationId; + } } /// Re-fetch the current page (Refresh button, and after a relay action). @@ -262,7 +285,8 @@ public partial class SiteCallsReport AfterCreatedAtUtc: cursor.AfterCreatedAtUtc, AfterId: cursor.AfterId, PageSize: PageSize, - SourceNodeFilter: NullIfEmpty(_nodeFilter)); + SourceNodeFilter: NullIfEmpty(_nodeFilter), + TrackedOperationIdFilter: _operationIdFilter); var response = await CommunicationService.QuerySiteCallsAsync(request); if (response.Success) @@ -476,9 +500,18 @@ public partial class SiteCallsReport _stuckOnly = false; _fromFilter = null; _toFilter = null; + _operationIdFilter = null; + } + + /// The Operation pill's own × — drop the deep-link filter and re-query. + private async Task ClearOperationFilter() + { + _operationIdFilter = null; + await Search(); } private bool HasActiveFilters => + _operationIdFilter != null || !string.IsNullOrEmpty(_statusFilter) || !string.IsNullOrEmpty(_channelFilter) || !string.IsNullOrEmpty(_siteFilter) || diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index 1c8cc84b..f777c95e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -151,6 +151,51 @@ public sealed class QueryStringDrillInTests Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")); } + // ----------------------------------------------------------------- + // SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2) + // ----------------------------------------------------------------- + + [Fact] + public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter() + { + // A well-formed GUID rides on the first query as the exact-match + // TrackedOperationIdFilter and renders the read-only Operation pill. + var op = Guid.Parse("44444444-4444-4444-4444-444444444444"); + using var ctx = new SiteCallsReportFixture(); + var nav = (BunitNavigationManager)ctx.Services.GetRequiredService(); + nav.NavigateTo($"/site-calls/report?correlationId={op}"); + + var cut = ctx.Render(); + + cut.WaitForAssertion(() => + { + Assert.Single(ctx.QueryRequests); + Assert.Equal(op, ctx.QueryRequests[0].TrackedOperationIdFilter); + var pill = cut.Find("[data-test='site-calls-filter-operation']"); + Assert.Contains(op.ToString("N")[..12], pill.TextContent); + }); + } + + [Fact] + public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored() + { + // Lax parsing, like ?status= — a value that is not a GUID leaves the + // filter null and renders no pill, so a hand-crafted bad URL loads the + // page unfiltered instead of breaking it. + using var ctx = new SiteCallsReportFixture(); + var nav = (BunitNavigationManager)ctx.Services.GetRequiredService(); + nav.NavigateTo("/site-calls/report?correlationId=nope"); + + var cut = ctx.Render(); + + cut.WaitForAssertion(() => + { + Assert.Single(ctx.QueryRequests); + Assert.Null(ctx.QueryRequests[0].TrackedOperationIdFilter); + }); + Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")); + } + // ----------------------------------------------------------------- // NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2) // ----------------------------------------------------------------- diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index 71477834..04565490 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -65,6 +65,16 @@ public class SiteCallsReportPageTests : BunitContext private readonly List _queryRequests = new(); private readonly List _retryRequests = new(); private readonly List _discardRequests = new(); + private readonly List _detailRequests = new(); + + // Scripted detail reply — the ?correlationId= auto-open path fetches the full + // row for the modal body. Overridable per test. + private SiteCallDetailResponse _detailReply = new( + "q", true, null, + new SiteCallDetail(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked", + RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503, + CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5), + TerminalAtUtc: null, IngestedAtUtc: DateTime.UtcNow.AddMinutes(-5))); // Scripted relay responses — overridable per test. private RetrySiteCallResponse _retryReply = @@ -506,6 +516,105 @@ public class SiteCallsReportPageTests : BunitContext }); } + // ───────────────────────────────────────────────────────────────────────── + // Flow Debugger / Audit Log deep link: ?correlationId= + // seeds a read-only "Operation" pill filter, rides on the query as + // TrackedOperationIdFilter, and auto-opens the detail modal when exactly + // one matching row comes back (follow-ups plan D2 / Task 4). + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail() + { + // The scripted query returns exactly the one row the deep link names. + _queryReply = _queryReply with + { + SiteCalls = _queryReply.SiteCalls.Where(c => c.TrackedOperationId == ParkedId).ToList(), + }; + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}"); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // The first query already carries the operation filter. + Assert.Single(_queryRequests); + Assert.Equal(ParkedId, _queryRequests[0].TrackedOperationIdFilter); + + // The filter card shows the read-only pill with the short id. + var pill = cut.Find("[data-test='site-calls-filter-operation']"); + Assert.Contains("Operation", pill.TextContent); + Assert.Contains(ParkedId.ToString("N")[..12], pill.TextContent); + + // Exactly one matching row → the detail modal opened on it. + var title = cut.Find(".modal .modal-title"); + Assert.Contains("Site Call Detail", title.TextContent); + Assert.Contains(ParkedId.ToString("N")[..12], title.TextContent); + Assert.Single(_detailRequests); + Assert.Equal(ParkedId, _detailRequests[0].TrackedOperationId); + }); + } + + [Fact] + public void SiteCallsReport_CorrelationIdQuery_NoMatchingRow_DoesNotOpenDetail() + { + // The default scripted reply carries two rows, neither of which is the + // requested id — the filter still rides on the query, but nothing + // auto-opens. + var other = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/site-calls/report?correlationId={other}"); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Single(_queryRequests); + Assert.Equal(other, _queryRequests[0].TrackedOperationIdFilter); + Assert.NotNull(cut.Find("[data-test='site-calls-filter-operation']")); + }); + Assert.Empty(cut.FindAll(".modal")); + Assert.Empty(_detailRequests); + } + + [Fact] + public void ClearFilters_DropsTheOperationFilter() + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}"); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='site-calls-filter-operation']"))); + + // The pill counts as an active filter, so Clear is enabled. + var clear = cut.FindAll("button").Single(b => b.TextContent.Trim() == "Clear"); + Assert.False(clear.HasAttribute("disabled")); + clear.Click(); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"))); + } + + [Fact] + public void OperationPill_ClearButton_DropsTheFilterAndRequeries() + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}"); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.Single(_queryRequests)); + + cut.Find("[data-test='site-calls-filter-operation-clear']").Click(); + + cut.WaitForAssertion(() => + { + Assert.Equal(2, _queryRequests.Count); + Assert.Null(_queryRequests[1].TrackedOperationIdFilter); + Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")); + }); + } + [Fact] public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows() { @@ -673,6 +782,11 @@ public class SiteCallsReportPageTests : BunitContext test._discardRequests.Add(r); Sender.Tell(test._discardReply); }); + Receive(r => + { + test._detailRequests.Add(r); + Sender.Tell(test._detailReply); + }); } }