feat(central-ui): Notification report accepts ?correlationId= and opens the notification's detail

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 11:04:29 -04:00
parent 2025ec346f
commit 8abc0eca13
3 changed files with 310 additions and 3 deletions
@@ -5,7 +5,9 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Communication
@using Microsoft.AspNetCore.WebUtilities
@inject CommunicationService CommunicationService
@inject NavigationManager Navigation
@inject ISiteRepository SiteRepository
@inject SiteScopeService SiteScope
@inject IDialogService Dialog
@@ -88,6 +90,20 @@
<label class="form-check-label small" for="no-stuck-only">Stuck only</label>
</div>
</div>
@if (_notificationIdFilter is { } notifFilter)
{
@* Register row 111 (D2): read-only exact-match pill seeded from the
?correlationId= deep link (Flow Debugger / Audit Log). Cleared by
its × (re-queries) or by Clear. *@
<div class="col-auto">
<span class="badge text-bg-secondary" data-test="notif-filter-notification"
title="@notifFilter">Notification @ShortId(notifFilter)</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 text-decoration-none"
data-test="notif-filter-notification-clear"
aria-label="Clear notification filter" title="Clear notification filter"
@onclick="ClearNotificationFilter">&times;</button>
</div>
}
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
@@ -439,6 +455,11 @@
private DateTime? _fromFilter;
private DateTime? _toFilter;
// Register row 111 (D2): exact-match NotificationId seeded from ?correlationId=.
// Not bound to a control — surfaced as a read-only pill in the filter card.
private string? _notificationIdFilter;
private const int MaxNotificationIdFilterLength = 64;
protected override async Task OnInitializedAsync()
{
try
@@ -457,7 +478,52 @@
Logger.LogWarning(ex, "Failed to load sites for the report source-site filter.");
}
// Seed the ?correlationId= filter BEFORE the first fetch so the initial grid
// load is already narrowed to the linked notification.
ApplyQueryStringFilters();
await RefreshAll();
// Deep link landed on exactly the row it named — open its detail so the
// operator does not have to find and double-click it.
if (_notificationIdFilter is { } wanted
&& _notifications is { Count: 1 }
&& _notifications[0].NotificationId == wanted)
{
await ShowDetail(_notifications[0]);
}
}
/// <summary>
/// Pre-apply the <c>?correlationId=</c> deep-link filter from the URL query
/// string (Flow Debugger External-lane node / Audit Log convention). The value
/// is the notification's own id (a string, not a GUID) — accepted trimmed when
/// non-blank and at most <see cref="MaxNotificationIdFilterLength"/> chars;
/// anything else is silently dropped, leaving the page unfiltered.
/// </summary>
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("correlationId", out var values))
{
var candidate = values.ToString()?.Trim();
if (!string.IsNullOrEmpty(candidate) && candidate.Length <= MaxNotificationIdFilterLength)
{
_notificationIdFilter = candidate;
}
}
}
private async Task ClearNotificationFilter()
{
_notificationIdFilter = null;
await Search();
}
private async Task RefreshAll()
@@ -491,7 +557,8 @@
To: ToUtc(_toFilter),
PageNumber: _pageNumber,
PageSize: _pageSize,
SourceNodeFilter: NullIfEmpty(_nodeFilter));
SourceNodeFilter: NullIfEmpty(_nodeFilter),
NotificationIdFilter: _notificationIdFilter);
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
if (response.Success)
@@ -693,9 +760,11 @@
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
_notificationIdFilter = null;
}
private bool HasActiveFilters =>
_notificationIdFilter != null ||
!string.IsNullOrEmpty(_statusFilter) ||
!string.IsNullOrEmpty(_typeFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||