feat(central-ui): Site Calls page accepts ?correlationId= and opens the operation's detail
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -90,6 +90,20 @@
|
||||
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
|
||||
</div>
|
||||
</div>
|
||||
@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. *@
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-secondary" data-test="site-calls-filter-operation"
|
||||
title="@operationId">Operation @ShortId(operationId)</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
|
||||
aria-label="Clear operation filter" title="Clear operation filter"
|
||||
data-test="site-calls-filter-operation-clear"
|
||||
@onclick="ClearOperationFilter" disabled="@_loading">×</button>
|
||||
</div>
|
||||
}
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
|
||||
disabled="@(!HasActiveFilters)">Clear</button>
|
||||
|
||||
+36
-3
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
|
||||
/// string. <c>?status=<status></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).
|
||||
/// <see cref="_stuckOnly"/>; <c>?correlationId=<guid></c> seeds
|
||||
/// <see cref="_operationIdFilter"/> (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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>The Operation pill's own × — drop the deep-link filter and re-query.</summary>
|
||||
private async Task ClearOperationFilter()
|
||||
{
|
||||
_operationIdFilter = null;
|
||||
await Search();
|
||||
}
|
||||
|
||||
private bool HasActiveFilters =>
|
||||
_operationIdFilter != null ||
|
||||
!string.IsNullOrEmpty(_statusFilter) ||
|
||||
!string.IsNullOrEmpty(_channelFilter) ||
|
||||
!string.IsNullOrEmpty(_siteFilter) ||
|
||||
|
||||
@@ -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<NavigationManager>();
|
||||
nav.NavigateTo($"/site-calls/report?correlationId={op}");
|
||||
|
||||
var cut = ctx.Render<SiteCallsReportPage>();
|
||||
|
||||
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<NavigationManager>();
|
||||
nav.NavigateTo("/site-calls/report?correlationId=nope");
|
||||
|
||||
var cut = ctx.Render<SiteCallsReportPage>();
|
||||
|
||||
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)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -65,6 +65,16 @@ public class SiteCallsReportPageTests : BunitContext
|
||||
private readonly List<SiteCallQueryRequest> _queryRequests = new();
|
||||
private readonly List<RetrySiteCallRequest> _retryRequests = new();
|
||||
private readonly List<DiscardSiteCallRequest> _discardRequests = new();
|
||||
private readonly List<SiteCallDetailRequest> _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=<TrackedOperationId>
|
||||
// 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<NavigationManager>();
|
||||
nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
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<NavigationManager>();
|
||||
nav.NavigateTo($"/site-calls/report?correlationId={other}");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
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<NavigationManager>();
|
||||
nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
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<NavigationManager>();
|
||||
nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
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<SiteCallDetailRequest>(r =>
|
||||
{
|
||||
test._detailRequests.Add(r);
|
||||
Sender.Tell(test._detailReply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user