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:
+53
-18
@@ -63,14 +63,16 @@
|
||||
To="_filterTo" ToChanged="(v => _filterTo = v)"
|
||||
IdPrefix="audit-filter" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
|
||||
Clear filters
|
||||
</button>
|
||||
<div class="col-md-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-primary" @onclick="Search" disabled="@_searching">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="ClearFilters" disabled="@_searching">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +109,7 @@
|
||||
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
|
||||
<tr>
|
||||
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
||||
<td class="small">@entry.User</td>
|
||||
<td class="small"><span class="cell-clip cell-clip-sm" title="@entry.User">@entry.User</span></td>
|
||||
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
|
||||
<td class="small">@entry.EntityType</td>
|
||||
<td class="small">
|
||||
@@ -124,13 +126,24 @@
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@entry.EntityName</td>
|
||||
<td class="small">
|
||||
@* Entity names are operator/bundle supplied — guarded and
|
||||
bounded exactly like the Entity ID neighbour above. *@
|
||||
@if (!string.IsNullOrEmpty(entry.EntityName))
|
||||
{
|
||||
<span class="cell-clip" title="@entry.EntityName">@entry.EntityName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (hasState)
|
||||
{
|
||||
if (isLarge)
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
@onclick="() => ShowStateModal(entry)"
|
||||
aria-label="Open state details in modal for audit entry @entry.Id">
|
||||
View in modal
|
||||
@@ -138,7 +151,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
@onclick="() => ToggleStateView(entry.Id)"
|
||||
aria-label="Toggle state details for audit entry @entry.Id">
|
||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
||||
@@ -166,7 +179,12 @@
|
||||
<OffsetPager Page="_page" PageChanged="OnPageChanged" HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />
|
||||
}
|
||||
|
||||
@if (_modalEntry != null)
|
||||
@* The state modal holds only the entry's id and re-resolves the row from the
|
||||
page currently on screen on every render (ModalEntry()). A refetch that
|
||||
replaces the entry list therefore never leaves this surface rendering a
|
||||
stale record, and an entry that has left the page closes the modal instead
|
||||
of stranding it. *@
|
||||
@if (ModalEntry() is { } modalEntry)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||
@@ -174,12 +192,12 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
|
||||
Audit entry @modalEntry.Id — @modalEntry.EntityType state
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(modalEntry.AfterStateJson!)</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
|
||||
@@ -217,12 +235,29 @@
|
||||
private bool _searching;
|
||||
private string? _errorMessage;
|
||||
private int? _expandedEntryId;
|
||||
private AuditLogEntry? _modalEntry;
|
||||
|
||||
// State modal. Only the entry's IDENTIFIER is held — never the AuditLogEntry
|
||||
// record itself — so the modal cannot outlive the row it was opened for.
|
||||
// ModalEntry() re-resolves it from the page currently on screen on every
|
||||
// render; an entry that has left the page resolves to null and the modal
|
||||
// renders nothing (it self-closes) rather than showing a stale snapshot.
|
||||
private int? _modalEntryId;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private bool HasMore => _page * _pageSize < _totalCount;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the entry the state modal is open for from the page currently on
|
||||
/// screen. Returns null when no entry is selected, when the list has not
|
||||
/// loaded, or when the selected entry is no longer in the page — the modal's
|
||||
/// visibility gate.
|
||||
/// </summary>
|
||||
private AuditLogEntry? ModalEntry() =>
|
||||
_modalEntryId is { } id
|
||||
? _entries?.FirstOrDefault(e => e.Id == id)
|
||||
: null;
|
||||
|
||||
// Tracks the BundleImportId we last fetched against so a re-render with the
|
||||
// same query param doesn't re-run the query on every parameter set.
|
||||
private Guid? _lastFetchedBundleImportId;
|
||||
@@ -327,12 +362,12 @@
|
||||
|
||||
private void ShowStateModal(AuditLogEntry entry)
|
||||
{
|
||||
_modalEntry = entry;
|
||||
_modalEntryId = entry.Id;
|
||||
}
|
||||
|
||||
private void CloseStateModal()
|
||||
{
|
||||
_modalEntry = null;
|
||||
_modalEntryId = null;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(string text)
|
||||
|
||||
Reference in New Issue
Block a user