refactor(ui/shared): introduce IDialogService + DialogHost

Eliminates the per-page <ConfirmDialog @ref="_confirmDialog"
ConfirmButtonClass="btn-danger" /> boilerplate. Pages now inject
IDialogService and call ConfirmAsync(title, message, danger: true)
programmatically.

New scoped service holds a single active dialog (throws on nested
calls), with a global DialogHost mounted once in MainLayout that
renders the modal markup, owns body scroll-lock via Bootstrap's
modal-open class, traps focus on the modal element, and handles
Escape-to-cancel.

Same service also exposes PromptAsync, used to replace the bespoke
NewFolderDialog. Both ConfirmDialog and NewFolderDialog components
are deleted — their callers (~13 pages across Admin/Design/Deployment
/Monitoring) now go through the service.

DiffDialog stays as-is — different use case (before/after content).

bUnit tests in TopologyPageTests, DataConnectionsPageTests, and
TemplatesPageTests register IDialogService in their service
collection.

Also: a top-of-file Razor comment on Sites.razor pointing future
implementers at it as the reference list-page pattern.
This commit is contained in:
Joseph Doherty
2026-05-12 03:57:37 -04:00
parent e21791adb0
commit 8038aa7cb5
21 changed files with 351 additions and 260 deletions
@@ -5,6 +5,7 @@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -13,7 +14,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -104,7 +104,6 @@
private string _search = string.Empty;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private IEnumerable<ApiKey> FilteredKeys =>
string.IsNullOrWhiteSpace(_search)
@@ -155,8 +154,10 @@
private async Task DeleteKey(ApiKey key)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete API key '{key.Name}'? This cannot be undone.", "Delete API Key");
var confirmed = await Dialog.ConfirmAsync(
"Delete API Key",
$"Delete API key '{key.Name}'? This cannot be undone.",
danger: true);
if (!confirmed) return;
try
@@ -6,6 +6,7 @@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -36,7 +37,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -183,7 +183,6 @@
private HashSet<string> _matchKeys = new();
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
@@ -293,8 +292,10 @@
private async Task DeleteConnection(DataConnection conn)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete data connection '{conn.Name}'?", "Delete Connection");
var confirmed = await Dialog.ConfirmAsync(
"Delete Connection",
$"Delete data connection '{conn.Name}'?",
danger: true);
if (!confirmed) return;
try
@@ -8,6 +8,7 @@
@inject ISecurityRepository SecurityRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="mb-3">
@@ -18,8 +19,6 @@
</button>
</div>
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Mapping</h5>
@@ -120,8 +119,6 @@
private int _scopeRuleSiteId;
private string? _scopeRuleError;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
@@ -209,9 +206,10 @@
private async Task DeleteScopeRule(SiteScopeRule rule)
{
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
var confirmed = await _confirmDialog.ShowAsync(
var confirmed = await Dialog.ConfirmAsync(
"Remove Scope Rule",
$"Remove scope rule for '{siteName}'?",
"Remove Scope Rule");
danger: true);
if (!confirmed) return;
try
@@ -1,3 +1,4 @@
@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@
@page "/admin/sites"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@@ -11,6 +12,7 @@
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -41,7 +43,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -173,7 +174,6 @@
private string _search = "";
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private IEnumerable<Site> FilteredSites =>
string.IsNullOrWhiteSpace(_search)
@@ -213,9 +213,10 @@
private async Task DeleteSite(Site site)
{
var confirmed = await _confirmDialog.ShowAsync(
var confirmed = await Dialog.ConfirmAsync(
"Delete Site",
$"Delete site '{site.Name}' ({site.SiteIdentifier})? This cannot be undone.",
"Delete Site");
danger: true);
if (!confirmed) return;