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

View File

@@ -9,6 +9,7 @@
@inject INotificationRepository NotificationRepository
@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">
@@ -18,7 +19,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -148,7 +148,6 @@
: _apiMethods.Where(m => m.Name?.Contains(_apiMethodSearch, StringComparison.OrdinalIgnoreCase) ?? false);
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
@@ -246,7 +245,7 @@
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await _confirmDialog.ShowAsync($"Delete '{es.Name}'?", "Delete External System")) return;
if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -318,7 +317,7 @@
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await _confirmDialog.ShowAsync($"Delete '{dc.Name}'?", "Delete DB Connection")) return;
if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -399,7 +398,7 @@
private async Task DeleteNotifList(NotificationList list)
{
if (!await _confirmDialog.ShowAsync($"Delete notification list '{list.Name}'?", "Delete")) return;
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true)) return;
try { await NotificationRepository.DeleteNotificationListAsync(list.Id); await NotificationRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -474,7 +473,7 @@
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await _confirmDialog.ShowAsync($"Delete API method '{m.Name}'?", "Delete")) return;
if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return;
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}

View File

@@ -8,6 +8,7 @@
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -16,7 +17,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -117,7 +117,6 @@
private string _search = "";
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private IEnumerable<SharedScript> FilteredScripts =>
string.IsNullOrWhiteSpace(_search)
@@ -148,8 +147,10 @@
private async Task DeleteScript(SharedScript script)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete shared script '{script.Name}'?", "Delete Shared Script");
var confirmed = await Dialog.ConfirmAsync(
"Delete Shared Script",
$"Delete shared script '{script.Name}'?",
danger: true);
if (!confirmed) return;
try

View File

@@ -12,7 +12,6 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -128,7 +127,6 @@
private string? _formError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{

View File

@@ -11,10 +11,10 @@
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm"
@@ -94,7 +94,6 @@
private string? _compFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnParametersSetAsync()
{
@@ -295,9 +294,10 @@
private async Task DeleteTemplate()
{
if (_selectedTemplate == null) return;
var confirmed = await _confirmDialog.ShowAsync(
var confirmed = await Dialog.ConfirmAsync(
"Delete Template",
$"Delete template '{_selectedTemplate.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
danger: true);
if (!confirmed) return;
try
@@ -816,7 +816,7 @@
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete attribute '{attr.Name}'?", "Delete Attribute");
var confirmed = await Dialog.ConfirmAsync("Delete Attribute", $"Delete attribute '{attr.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAttributeAsync(attr.Id, user);
@@ -861,7 +861,7 @@
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete alarm '{alarm.Name}'?", "Delete Alarm");
var confirmed = await Dialog.ConfirmAsync("Delete Alarm", $"Delete alarm '{alarm.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, user);
@@ -903,7 +903,7 @@
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete script '{script.Name}'?", "Delete Script");
var confirmed = await Dialog.ConfirmAsync("Delete Script", $"Delete script '{script.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteScriptAsync(script.Id, user);
@@ -939,7 +939,7 @@
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await _confirmDialog.ShowAsync($"Remove composition '{comp.InstanceName}'?", "Delete Composition");
var confirmed = await Dialog.ConfirmAsync("Delete Composition", $"Remove composition '{comp.InstanceName}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(comp.Id, user);

View File

@@ -10,10 +10,10 @@
@inject TemplateFolderService TemplateFolderService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
FolderId="_renameFolderId"
@@ -21,11 +21,6 @@
ErrorMessage="@_renameFolderError"
OnSubmit="SubmitRenameFolder" />
<NewFolderDialog @bind-IsVisible="_showNewFolderDialog"
ParentFolderId="_newFolderParentId"
ErrorMessage="@_newFolderError"
OnSubmit="SubmitNewFolder" />
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
TemplateId="_moveTemplateId"
TemplateName="@_moveTemplateName"
@@ -112,7 +107,6 @@
private string? _errorMessage;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
@@ -345,32 +339,24 @@
}
};
// New-folder dialog state
private bool _showNewFolderDialog;
private int? _newFolderParentId;
private string? _newFolderError;
private void OpenNewFolderDialog(int? parentFolderId)
// New-folder dialog: replaced the dedicated <NewFolderDialog> component with
// IDialogService.PromptAsync. Validation failures surface via toast instead of
// inline error text — the prompt UI doesn't have a slot for an error message.
private async Task OpenNewFolderDialog(int? parentFolderId)
{
_newFolderParentId = parentFolderId;
_newFolderError = null;
_showNewFolderDialog = true;
}
var name = await Dialog.PromptAsync("New folder", "Folder name", placeholder: "Folder name");
if (string.IsNullOrWhiteSpace(name)) return;
private async Task SubmitNewFolder((int? ParentFolderId, string Name) req)
{
_newFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.CreateFolderAsync(req.Name, req.ParentFolderId, user);
var result = await TemplateFolderService.CreateFolderAsync(name.Trim(), parentFolderId, user);
if (result.IsSuccess)
{
_showNewFolderDialog = false;
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
await LoadTemplatesAsync();
}
else
{
_newFolderError = result.Error;
_toast.ShowError(result.Error);
}
}
@@ -497,7 +483,7 @@
private async Task DeleteFolder(int folderId, string label)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete folder '{label}'?", "Delete Folder");
var confirmed = await Dialog.ConfirmAsync("Delete Folder", $"Delete folder '{label}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
@@ -515,9 +501,10 @@
private async Task DeleteTemplate(Template template)
{
var confirmed = await _confirmDialog.ShowAsync(
var confirmed = await Dialog.ConfirmAsync(
"Delete Template",
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
danger: true);
if (!confirmed) return;
try