ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
+4
-3
@@ -107,11 +107,12 @@
|
||||
{
|
||||
@foreach (var r in _recipients)
|
||||
{
|
||||
var contact = _type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress;
|
||||
<tr>
|
||||
<td>@r.Name</td>
|
||||
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
|
||||
<td><span class="cell-clip cell-clip-sm" title="@r.Name">@r.Name</span></td>
|
||||
<td><span class="cell-clip" title="@contact">@contact</span></td>
|
||||
<td>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteRecipient(r)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
+44
-12
@@ -57,7 +57,7 @@
|
||||
var recipients = _recipients.GetValueOrDefault(list.Id)
|
||||
?? (IReadOnlyList<NotificationRecipient>)Array.Empty<NotificationRecipient>();
|
||||
<tr @key="list.Id">
|
||||
<td>@list.Name</td>
|
||||
<td><span class="cell-clip" title="@list.Name">@list.Name</span></td>
|
||||
<td>@list.Type</td>
|
||||
<td>
|
||||
@if (recipients.Count == 0)
|
||||
@@ -66,27 +66,42 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var r in recipients)
|
||||
@foreach (var r in recipients.Take(MaxVisibleRecipients))
|
||||
{
|
||||
@* Type-aware contact: SMS recipients carry a PhoneNumber and a null
|
||||
EmailAddress, so an email-shaped badge would render "Name <>". Fall
|
||||
back to whichever contact field is populated. *@
|
||||
back to whichever contact field is populated. The two identifiers
|
||||
are separated inside the chip — name, then contact in a lighter
|
||||
weight — rather than fused into one "Name <contact>" run. *@
|
||||
var contact = list.Type == NotificationType.Sms
|
||||
? r.PhoneNumber
|
||||
: r.EmailAddress;
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1">@r.Name <@contact></span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"
|
||||
title="@r.Name — @contact">@r.Name<span class="fw-normal opacity-75 ms-1">@contact</span></span>
|
||||
}
|
||||
@if (recipients.Count > MaxVisibleRecipients)
|
||||
{
|
||||
@* Long lists roll up rather than filling the cell with chips.
|
||||
The remainder stays readable on the tooltip, and the full set
|
||||
is always available on the list's Edit page. *@
|
||||
<span class="badge bg-body-secondary text-body-secondary border"
|
||||
title="@RemainingRecipientsTitle(list, recipients)">
|
||||
+@(recipients.Count - MaxVisibleRecipients) more
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-outline-primary btn-sm me-1"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => DeleteList(list)">
|
||||
Delete
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DeleteList(list)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -97,6 +112,13 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Recipient chips rendered inline before the "+N more" roll-up. A list may
|
||||
/// hold dozens of recipients and the cell is uncapped, so the grid would
|
||||
/// otherwise render one chip per recipient into a single row.
|
||||
/// </summary>
|
||||
private const int MaxVisibleRecipients = 5;
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private List<NotificationList> _lists = new();
|
||||
@@ -125,6 +147,16 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip for the "+N more" roll-up chip: the recipients that were not given
|
||||
/// a chip of their own, using the same type-aware contact field as the chips.
|
||||
/// </summary>
|
||||
private static string RemainingRecipientsTitle(
|
||||
NotificationList list, IReadOnlyList<NotificationRecipient> recipients) =>
|
||||
string.Join(", ", recipients
|
||||
.Skip(MaxVisibleRecipients)
|
||||
.Select(r => $"{r.Name} — {(list.Type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)}"));
|
||||
|
||||
private async Task DeleteList(NotificationList list)
|
||||
{
|
||||
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
|
||||
|
||||
+58
-32
@@ -153,7 +153,9 @@
|
||||
<td>@n.Type</td>
|
||||
<td>@n.ListName</td>
|
||||
<td>
|
||||
@n.Subject
|
||||
@* Subject is script-authored free text, so it is bounded
|
||||
the same way the Last error line below it is. *@
|
||||
<span class="cell-clip" title="@n.Subject">@n.Subject</span>
|
||||
@if (!string.IsNullOrEmpty(n.LastError))
|
||||
{
|
||||
<div class="small text-danger text-truncate" style="max-width: 320px;"
|
||||
@@ -176,22 +178,24 @@
|
||||
@* NotificationId is the audit
|
||||
CorrelationId, so the link deep-links into the central Audit
|
||||
Log pre-filtered to this notification's lifecycle events. *@
|
||||
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||
href="/audit/log?correlationId=@n.NotificationId"
|
||||
data-test="audit-link-@n.NotificationId">
|
||||
View audit history
|
||||
</a>
|
||||
@if (n.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a class="btn btn-outline-secondary"
|
||||
href="/audit/log?correlationId=@n.NotificationId"
|
||||
data-test="audit-link-@n.NotificationId">
|
||||
View audit history
|
||||
</a>
|
||||
@if (n.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -209,10 +213,14 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Row detail modal ── *@
|
||||
@if (_detailNotification != null)
|
||||
@* ── Row detail modal ──
|
||||
The modal holds only the row's NotificationId and re-resolves the summary from
|
||||
the page currently on screen on every render (DetailRow()). A refresh that
|
||||
replaces the row list therefore never leaves this surface rendering a stale
|
||||
record, and a row that has left the page closes the modal instead of stranding
|
||||
it. *@
|
||||
@if (DetailRow() is { } d)
|
||||
{
|
||||
var d = _detailNotification;
|
||||
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
|
||||
@onclick="CloseDetail">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
|
||||
@@ -334,14 +342,16 @@
|
||||
<div class="modal-footer">
|
||||
@if (d.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm"
|
||||
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
|
||||
</div>
|
||||
@@ -370,12 +380,28 @@
|
||||
private string? _listError;
|
||||
private bool _actionInProgress;
|
||||
|
||||
// Row detail modal
|
||||
private NotificationSummary? _detailNotification;
|
||||
// Row detail modal. Only the row's IDENTIFIER is held — never the
|
||||
// NotificationSummary record itself — so the modal cannot outlive the row it
|
||||
// was opened for. DetailRow() re-resolves it from the page currently on screen
|
||||
// on every render; a row that has left the page resolves to null and the modal
|
||||
// renders nothing (it self-closes) rather than showing a stale snapshot.
|
||||
private string? _detailNotificationId;
|
||||
private NotificationDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the row the detail modal is open for from the page currently on
|
||||
/// screen. Returns null when no row is selected, when the list has not loaded,
|
||||
/// or when the selected row is no longer in the page — the modal's visibility
|
||||
/// gate, so a refresh that drops the row closes the surface instead of leaving
|
||||
/// it stranded on a stale record.
|
||||
/// </summary>
|
||||
private NotificationSummary? DetailRow() =>
|
||||
_detailNotificationId is { } id
|
||||
? _notifications?.FirstOrDefault(n => n.NotificationId == id)
|
||||
: null;
|
||||
|
||||
// Filters
|
||||
private string _statusFilter = string.Empty;
|
||||
private string _typeFilter = string.Empty;
|
||||
@@ -544,7 +570,7 @@
|
||||
{
|
||||
// The summary fields render immediately; Body + recipients fill in once the
|
||||
// full-detail fetch completes.
|
||||
_detailNotification = n;
|
||||
_detailNotificationId = n.NotificationId;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
@@ -572,7 +598,7 @@
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailNotification = null;
|
||||
_detailNotificationId = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
|
||||
+9
-3
@@ -53,7 +53,11 @@
|
||||
<div class="col-md-4 text-muted">Messaging Service SID</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.MessagingServiceSid) ? "(not set)" : sms.MessagingServiceSid)</div>
|
||||
<div class="col-md-4 text-muted">API Base URL</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</div>
|
||||
@* An override base URL is an operator-supplied URL with no
|
||||
break opportunities — clip it so it cannot widen the card. *@
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@sms.ApiBaseUrl">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">Auth Token</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.AuthToken) ? "(not set)" : "(stored)")</div>
|
||||
<div class="col-md-4 text-muted">Connection Timeout</div>
|
||||
@@ -122,8 +126,10 @@
|
||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||
}
|
||||
<div class="col-12 text-end">
|
||||
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+25
-8
@@ -38,7 +38,10 @@
|
||||
{
|
||||
<div class="card mb-3" @key="smtp.Id">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@smtp.Host</strong>
|
||||
@* Under the Ews transport Host is a full EWS endpoint URL, not a
|
||||
host name — clip the card title so it cannot push the Edit
|
||||
button off the header. *@
|
||||
<strong class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</strong>
|
||||
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
|
||||
{
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
|
||||
@@ -51,12 +54,16 @@
|
||||
@if (IsEws(smtp.Transport))
|
||||
{
|
||||
<div class="col-md-4 text-muted">EWS URL</div>
|
||||
<div class="col-md-8">@smtp.Host</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4 text-muted">Host</div>
|
||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@($"{smtp.Host}:{smtp.Port}")">@smtp.Host:@smtp.Port</span>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">Auth Type</div>
|
||||
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
||||
@@ -66,15 +73,23 @@
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">From Address</div>
|
||||
<div class="col-md-8">@smtp.FromAddress</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip" title="@smtp.FromAddress">@smtp.FromAddress</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">Credentials</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
|
||||
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@* Authority/Scope are operator-supplied URLs/URIs with no
|
||||
break opportunities — clipped for the same reason. *@
|
||||
<div class="col-md-4 text-muted">OAuth2 Authority</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Authority">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">OAuth2 Scope</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Scope">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,8 +167,10 @@
|
||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||
}
|
||||
<div class="col-12 text-end">
|
||||
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user