feat(ui): notification detail modal shows message body + recipients

This commit is contained in:
Joseph Doherty
2026-05-21 02:49:17 -04:00
parent 194cae2fbf
commit babf5b99e7
2 changed files with 243 additions and 2 deletions

View File

@@ -268,6 +268,64 @@
<dd class="col-sm-9 text-danger">@d.LastError</dd> <dd class="col-sm-9 text-danger">@d.LastError</dd>
} }
</dl> </dl>
@* ── Recipients ── *@
<hr />
<h6 class="mb-2">Recipients</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var recipients = ParseRecipients(_detail.ResolvedTargets);
if (recipients.Count > 0)
{
<ul class="mb-0">
@foreach (var recipient in recipients)
{
<li>@recipient</li>
}
</ul>
}
else
{
<div class="text-muted small">
Not yet resolved — recipients are resolved from list
"@d.ListName" at delivery time.
</div>
}
}
@* ── Body ── *@
<hr />
<h6 class="mb-2">Message body</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
@* Email bodies are plain text (design: BCC delivery, plain text).
Rendered as preformatted text — never as a MarkupString, which
would be an XSS vector. *@
<pre class="border rounded bg-light p-2 mb-0"
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@if (d.Status == "Parked") @if (d.Status == "Parked")
@@ -304,6 +362,9 @@
// Row detail modal // Row detail modal
private NotificationSummary? _detailNotification; private NotificationSummary? _detailNotification;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters // Filters
private string _statusFilter = string.Empty; private string _statusFilter = string.Empty;
@@ -440,9 +501,80 @@
_actionInProgress = false; _actionInProgress = false;
} }
private void ShowDetail(NotificationSummary n) => _detailNotification = n; private async Task ShowDetail(NotificationSummary n)
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
private void CloseDetail() => _detailNotification = null; try
{
var response = await CommunicationService.GetNotificationDetailAsync(
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load notification detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailNotification = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
/// <summary>
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
/// The field may be a JSON string array, or a comma/semicolon-separated string.
/// Returns an empty list when null/empty.
/// </summary>
private static List<string> ParseRecipients(string? resolvedTargets)
{
if (string.IsNullOrWhiteSpace(resolvedTargets))
{
return new List<string>();
}
var trimmed = resolvedTargets.Trim();
if (trimmed.StartsWith('['))
{
try
{
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
if (parsed != null)
{
return parsed
.Where(r => !string.IsNullOrWhiteSpace(r))
.Select(r => r.Trim())
.ToList();
}
}
catch (System.Text.Json.JsonException)
{
// Not valid JSON — fall through to the delimiter-split path.
}
}
return trimmed
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
private async Task RetryFromDetail(NotificationSummary n) private async Task RetryFromDetail(NotificationSummary n)
{ {

View File

@@ -29,6 +29,27 @@ public class NotificationReportDetailModalTests : BunitContext
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests"); private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
private readonly CommunicationService _comms; private readonly CommunicationService _comms;
private NotificationDetailResponse _detailReply =
new("d", true, null, new NotificationDetail(
NotificationId: "notif-aaaaaaaa-1111-full-id",
Type: "Email",
ListName: "Ops On-Call",
Subject: "Pump fault at Plant-A",
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
Status: "Parked",
RetryCount: 3,
LastError: "SMTP timeout connecting to mail relay",
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
TypeData: null,
SourceSiteId: "plant-a",
SourceInstanceId: "Pump-001",
SourceScript: "PumpFault.csx",
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
NextAttemptAt: null,
DeliveredAt: null));
private NotificationOutboxQueryResponse _queryReply = private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary> new("q", true, null, new List<NotificationSummary>
{ {
@@ -149,6 +170,90 @@ public class NotificationReportDetailModalTests : BunitContext
}); });
} }
[Fact]
public void Modal_FetchesAndShowsBody()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains(
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
modal.TextContent);
});
}
[Fact]
public void Modal_ShowsRecipients_FromResolvedTargets()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("ops@example.com", modal.TextContent);
Assert.Contains("oncall@example.com", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
{
_detailReply = _detailReply with
{
Detail = _detailReply.Detail! with { ResolvedTargets = null },
};
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("Not yet resolved", modal.TextContent);
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("at delivery time", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsError_WhenDetailFetchFails()
{
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
// The error surfaces in the body/recipient sections...
Assert.Contains("detail store unavailable", modal.TextContent);
// ...but the summary fields (from the grid row) still render.
Assert.Contains("Ops On-Call", modal.TextContent);
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
});
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
@@ -163,6 +268,10 @@ public class NotificationReportDetailModalTests : BunitContext
public ScriptedOutboxActor(NotificationReportDetailModalTests test) public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{ {
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply)); Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
{
CorrelationId = r.CorrelationId,
}));
Receive<RetryNotificationRequest>(r => Receive<RetryNotificationRequest>(r =>
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null))); Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
Receive<DiscardNotificationRequest>(r => Receive<DiscardNotificationRequest>(r =>