From 8038aa7cb56f246f78a48c6f96c801119af20331 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 03:57:37 -0400 Subject: [PATCH] refactor(ui/shared): introduce IDialogService + DialogHost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the per-page 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. --- .../Components/Layout/MainLayout.razor | 4 + .../Components/Pages/Admin/ApiKeys.razor | 9 +- .../Pages/Admin/DataConnections.razor | 9 +- .../Pages/Admin/LdapMappingForm.razor | 10 +- .../Components/Pages/Admin/Sites.razor | 9 +- .../Pages/Deployment/Topology.razor | 18 +-- .../Pages/Design/ExternalSystems.razor | 11 +- .../Pages/Design/SharedScripts.razor | 9 +- .../Pages/Design/SmtpConfiguration.razor | 2 - .../Pages/Design/TemplateEdit.razor | 16 +-- .../Components/Pages/Design/Templates.razor | 39 ++--- .../Pages/Monitoring/ParkedMessages.razor | 8 +- .../Components/Shared/ConfirmDialog.razor | 131 ----------------- .../Components/Shared/DialogHost.razor | 136 ++++++++++++++++++ .../Components/Shared/DialogService.cs | 94 ++++++++++++ .../Components/Shared/IDialogService.cs | 32 +++++ .../Components/Shared/NewFolderDialog.razor | 53 ------- .../ServiceCollectionExtensions.cs | 6 + .../DataConnectionsPageTests.cs | 4 + .../TemplatesPageTests.cs | 5 + .../TopologyPageTests.cs | 6 + 21 files changed, 351 insertions(+), 260 deletions(-) delete mode 100644 src/ScadaLink.CentralUI/Components/Shared/ConfirmDialog.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/DialogHost.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/DialogService.cs create mode 100644 src/ScadaLink.CentralUI/Components/Shared/IDialogService.cs delete mode 100644 src/ScadaLink.CentralUI/Components/Shared/NewFolderDialog.razor diff --git a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor index 24468b9..1e87d7d 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor @@ -21,3 +21,7 @@ @Body + +@* Global host for IDialogService. One instance per layout renders all confirm/prompt + dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@ + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor index baee409..0a5bed0 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -5,6 +5,7 @@ @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject IInboundApiRepository InboundApiRepository @inject NavigationManager NavigationManager +@inject IDialogService Dialog
@@ -13,7 +14,6 @@
- @if (_loading) { @@ -104,7 +104,6 @@ private string _search = string.Empty; private ToastNotification _toast = default!; - private ConfirmDialog _confirmDialog = default!; private IEnumerable 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 diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor index 0e952a5..9a4a237 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor @@ -6,6 +6,7 @@ @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject ISiteRepository SiteRepository @inject NavigationManager NavigationManager +@inject IDialogService Dialog
@@ -36,7 +37,6 @@
- @if (_loading) { @@ -183,7 +183,6 @@ private HashSet _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 diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor index a5183f9..6d969ad 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor @@ -8,6 +8,7 @@ @inject ISecurityRepository SecurityRepository @inject ISiteRepository SiteRepository @inject NavigationManager NavigationManager +@inject IDialogService Dialog
@@ -18,8 +19,6 @@
- -
Mapping
@@ -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 diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor index 8261e78..6b04d47 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor @@ -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
@@ -41,7 +43,6 @@
- @if (_loading) { @@ -173,7 +174,6 @@ private string _search = ""; private ToastNotification _toast = default!; - private ConfirmDialog _confirmDialog = default!; private IEnumerable 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; diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor index 55262d1..5e8c0ca 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor @@ -19,11 +19,11 @@ @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime +@inject IDialogService Dialog @implements IDisposable
-
@@ -18,7 +19,6 @@
- @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); } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor index 2557fe7..18630b7 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor @@ -8,6 +8,7 @@ @inject SharedScriptService SharedScriptService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager +@inject IDialogService Dialog
@@ -16,7 +17,6 @@
- @if (_loading) { @@ -117,7 +117,6 @@ private string _search = ""; private ToastNotification _toast = default!; - private ConfirmDialog _confirmDialog = default!; private IEnumerable 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 diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor index ee57e44..e6dd7f4 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor @@ -12,7 +12,6 @@
- @if (_loading) { @@ -128,7 +127,6 @@ private string? _formError; private ToastNotification _toast = default!; - private ConfirmDialog _confirmDialog = default!; protected override async Task OnInitializedAsync() { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 1754e1d..f0fa16e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -11,10 +11,10 @@ @inject TemplateService TemplateService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager +@inject IDialogService Dialog
-
-
- - -
-
-
-} - -@code { - private bool _visible; - private bool _bodyLocked; - private TaskCompletionSource? _tcs; - private ElementReference _modalRef; - - [Parameter] public string Title { get; set; } = "Confirm"; - [Parameter] public string Message { get; set; } = "Are you sure?"; - [Parameter] public string ConfirmText { get; set; } = "Confirm"; - [Parameter] public string ConfirmButtonClass { get; set; } = "btn-primary"; - - public Task ShowAsync(string? message = null, string? title = null) - { - if (message != null) Message = message; - if (title != null) Title = title; - _visible = true; - _tcs = new TaskCompletionSource(); - StateHasChanged(); - return _tcs.Task; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (_visible && !_bodyLocked) - { - _bodyLocked = true; - await TryLockBodyAsync(); - // Focus the modal so the @onkeydown handler receives Escape. - try { await _modalRef.FocusAsync(); } - catch { /* prerender or detached: ignore */ } - } - } - - private async Task OnKeyDownAsync(KeyboardEventArgs e) - { - if (e.Key == "Escape") - { - await CancelAsync(); - } - } - - private void Confirm() - { - Close(true); - } - - private void Cancel() - { - Close(false); - } - - private Task CancelAsync() - { - Close(false); - return Task.CompletedTask; - } - - private void Close(bool result) - { - _visible = false; - _ = TryUnlockBodyAsync(); - _tcs?.TrySetResult(result); - } - - private async Task TryLockBodyAsync() - { - try - { - await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); - } - catch - { - // Prerendering has no JS runtime; log only. - try { await JS.InvokeVoidAsync("console.debug", "ConfirmDialog: JS interop unavailable for body lock."); } - catch { /* swallow */ } - } - } - - private async Task TryUnlockBodyAsync() - { - _bodyLocked = false; - try - { - await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); - } - catch - { - try { await JS.InvokeVoidAsync("console.debug", "ConfirmDialog: JS interop unavailable for body unlock."); } - catch { /* swallow */ } - } - } - - public async ValueTask DisposeAsync() - { - if (_bodyLocked) - { - await TryUnlockBodyAsync(); - } - } -} diff --git a/src/ScadaLink.CentralUI/Components/Shared/DialogHost.razor b/src/ScadaLink.CentralUI/Components/Shared/DialogHost.razor new file mode 100644 index 0000000..1cf4c4d --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/DialogHost.razor @@ -0,0 +1,136 @@ +@* Single global host component for IDialogService. Mounted once in MainLayout. + Listens to DialogService.OnChange and renders the current dialog state. + z-index ladder follows the same convention as ConfirmDialog/DiffDialog: + Toast container 1090 > this modal 1055 > this backdrop 1040. *@ +@implements IDisposable +@inject IDialogService Service +@inject IJSRuntime JS + +@if (Service is DialogService svc && svc.Current is { } state) +{ + + +} + +@code { + private ElementReference _modalRef; + private string _promptValue = string.Empty; + private DialogState? _lastSeenState; + + protected override void OnInitialized() + { + // OnChange lives on the concrete DialogService — the interface stays + // narrow (just ConfirmAsync / PromptAsync). DI hands us the concrete + // instance, so a cast here is safe. + if (Service is DialogService svc) svc.OnChange += OnServiceChanged; + } + + public void Dispose() + { + if (Service is DialogService svc) svc.OnChange -= OnServiceChanged; + } + + private void OnServiceChanged() + { + // Seed prompt input value when a new prompt dialog opens. + if (Service is DialogService s && s.Current is { Kind: DialogKind.Prompt } promptState + && !ReferenceEquals(promptState, _lastSeenState)) + { + _promptValue = promptState.PromptInitial; + } + _lastSeenState = (Service as DialogService)?.Current; + InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var current = (Service as DialogService)?.Current; + if (current is not null) + { + try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); } + catch { /* prerender: no JS — ignore */ } + try { await _modalRef.FocusAsync(); } + catch { /* element not yet attached: ignore */ } + } + else + { + try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); } + catch { /* prerender: no JS — ignore */ } + } + } + + private void OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + Cancel(); + } + } + + private void OnPromptInput(ChangeEventArgs e) + { + _promptValue = e.Value?.ToString() ?? string.Empty; + } + + private void Cancel() + { + if (Service is not DialogService svc || svc.Current is null) return; + var resolveValue = svc.Current.Kind == DialogKind.Confirm + ? (object)false + : (object?)null; + _promptValue = string.Empty; + svc.Resolve(resolveValue); + } + + private void Confirm() + { + if (Service is not DialogService svc || svc.Current is null) return; + var resolveValue = svc.Current.Kind == DialogKind.Confirm + ? (object)true + : (object?)_promptValue; + _promptValue = string.Empty; + svc.Resolve(resolveValue); + } + + private static string ConfirmLabel(DialogState state) => state.Kind switch + { + DialogKind.Prompt => "Save", + _ => state.Danger ? "Delete" : "Confirm", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/DialogService.cs b/src/ScadaLink.CentralUI/Components/Shared/DialogService.cs new file mode 100644 index 0000000..c136bfb --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/DialogService.cs @@ -0,0 +1,94 @@ +namespace ScadaLink.CentralUI.Components.Shared; + +/// +/// Default implementation. Holds the currently +/// open dialog state in and notifies subscribers (the +/// DialogHost component) via . Only a single +/// dialog can be open at a time; attempting to open another while one is +/// already active throws — there is +/// no nested-dialog use case today and surfacing the bug is preferable to +/// silently queuing. +/// +public class DialogService : IDialogService +{ + /// + /// Raised whenever changes (dialog opened or closed). + /// The host component subscribes and calls StateHasChanged. + /// + public event Action? OnChange; + + /// + /// The dialog currently being displayed, or null when no dialog is + /// open. The host reads this to decide what (if anything) to render. + /// + public DialogState? Current { get; private set; } + + private TaskCompletionSource? _tcs; + + public Task ConfirmAsync(string title, string message, bool danger = false) + { + EnsureNoActiveDialog(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tcs = tcs; + Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null); + OnChange?.Invoke(); + return tcs.Task.ContinueWith(t => t.Result is bool b && b, TaskScheduler.Default); + } + + public Task PromptAsync(string title, string label, string initialValue = "", string? placeholder = null) + { + EnsureNoActiveDialog(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _tcs = tcs; + Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder); + OnChange?.Invoke(); + return tcs.Task.ContinueWith(t => t.Result as string, TaskScheduler.Default); + } + + /// + /// Called by the host component when the user dismisses or confirms the + /// dialog. must be a bool for confirms + /// and a string? for prompts (null = cancel). + /// + internal void Resolve(object? result) + { + var tcs = _tcs; + _tcs = null; + Current = null; + OnChange?.Invoke(); + tcs?.TrySetResult(result); + } + + private void EnsureNoActiveDialog() + { + if (Current is not null) + { + throw new InvalidOperationException( + "A dialog is already open. IDialogService does not support nested dialogs."); + } + } +} + +/// +/// Snapshot of a dialog's display state, exposed read-only on +/// for the host component to render. +/// +/// Modal title text. +/// Discriminates between confirm and prompt rendering. +/// For confirm: the message; for prompt: the input label. +/// When true, the confirm button uses danger styling. +/// Initial value for prompt-kind dialogs. +/// Placeholder shown when the prompt input is empty. +public record DialogState( + string Title, + DialogKind Kind, + string Body, + bool Danger, + string PromptInitial, + string? Placeholder); + +public enum DialogKind +{ + Confirm, + Prompt +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/IDialogService.cs b/src/ScadaLink.CentralUI/Components/Shared/IDialogService.cs new file mode 100644 index 0000000..6f51a3b --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/IDialogService.cs @@ -0,0 +1,32 @@ +namespace ScadaLink.CentralUI.Components.Shared; + +/// +/// Centralised dialog/modal service. Pages inject this service and call +/// or programmatically +/// instead of embedding per-page modal components. A single DialogHost +/// rendered in MainLayout displays the resulting dialog state. +/// +public interface IDialogService +{ + /// + /// Shows a confirmation dialog and resolves to true when the user + /// confirms, or false when the user cancels (button click, Escape, + /// or backdrop dismiss). + /// + /// Modal title text. + /// Body text shown to the user. + /// When true, the confirm button renders in + /// btn-danger styling with the label "Delete"; otherwise a primary + /// "Confirm" button is shown. + Task ConfirmAsync(string title, string message, bool danger = false); + + /// + /// Shows a single-line text prompt and resolves to the entered value, or + /// null if the user cancels. + /// + /// Modal title text. + /// Label rendered above the input field. + /// Pre-populated value for the input field. + /// Optional placeholder shown when the input is empty. + Task PromptAsync(string title, string label, string initialValue = "", string? placeholder = null); +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/NewFolderDialog.razor b/src/ScadaLink.CentralUI/Components/Shared/NewFolderDialog.razor deleted file mode 100644 index 5e0411e..0000000 --- a/src/ScadaLink.CentralUI/Components/Shared/NewFolderDialog.razor +++ /dev/null @@ -1,53 +0,0 @@ -@if (IsVisible) -{ - - -} - -@code { - [Parameter] public bool IsVisible { get; set; } - [Parameter] public EventCallback IsVisibleChanged { get; set; } - [Parameter] public int? ParentFolderId { get; set; } - [Parameter] public string? ErrorMessage { get; set; } - [Parameter] public EventCallback<(int? ParentFolderId, string Name)> OnSubmit { get; set; } - - private bool _wasVisible; - private string _name = string.Empty; - - protected override void OnParametersSet() - { - // Reset internal state on transition from hidden -> visible. - if (IsVisible && !_wasVisible) - { - _name = string.Empty; - } - _wasVisible = IsVisible; - } - - private async Task Close() - { - await IsVisibleChanged.InvokeAsync(false); - } - - private async Task Submit() - { - await OnSubmit.InvokeAsync((ParentFolderId, _name.Trim())); - } -} diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index aeb91e3..d8cc906 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using ScadaLink.CentralUI.Auth; +using ScadaLink.CentralUI.Components.Shared; namespace ScadaLink.CentralUI; @@ -16,6 +17,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddCascadingAuthenticationState(); + // Centralised dialog service: pages inject IDialogService and a single + // in MainLayout renders the active dialog. See + // Components/Shared/IDialogService.cs. + services.AddScoped(); + return services; } } diff --git a/tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs index bdf0cbb..ca257b5 100644 --- a/tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs @@ -3,6 +3,7 @@ using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; +using ScadaLink.CentralUI.Components.Shared; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using DataConnectionsPage = ScadaLink.CentralUI.Components.Pages.Admin.DataConnections; @@ -21,6 +22,9 @@ public class DataConnectionsPageTests : BunitContext public DataConnectionsPageTests() { Services.AddSingleton(_siteRepo); + // Satisfy the page's [Inject] IDialogService — the host that actually + // renders the dialog lives in MainLayout, not in bUnit's render scope. + Services.AddScoped(); AddTestAuth(); JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); diff --git a/tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs b/tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs index a0d9647..e6a7d82 100644 --- a/tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs @@ -6,6 +6,7 @@ using NSubstitute; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.CentralUI.Components.Shared; using ScadaLink.TemplateEngine; using ScadaLink.TemplateEngine.Services; using TemplatesPage = ScadaLink.CentralUI.Components.Pages.Design.Templates; @@ -30,6 +31,10 @@ public class TemplatesPageTests : BunitContext Services.AddSingleton(_audit); Services.AddScoped(); Services.AddScoped(); + // The Templates page injects IDialogService for the new-folder prompt + // and delete confirmations. The host is rendered in MainLayout, not + // here, but the DI registration still has to satisfy the [Inject]. + Services.AddScoped(); AddTestAuth(); // The TreeView inside the page persists expansion state via JS interop diff --git a/tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs b/tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs index d6c31c8..bcf74d7 100644 --- a/tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs @@ -14,6 +14,7 @@ using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; using ScadaLink.Communication; using ScadaLink.DeploymentManager; +using ScadaLink.CentralUI.Components.Shared; using ScadaLink.TemplateEngine.Services; using TopologyPage = ScadaLink.CentralUI.Components.Pages.Deployment.Topology; @@ -59,6 +60,11 @@ public class TopologyPageTests : BunitContext AddTestAuth(); + // The page injects IDialogService for delete confirmations; the host + // (rendered globally in MainLayout) is not present in bUnit, but the + // DI registration still has to satisfy the [Inject]. + Services.AddScoped(); + // TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw. JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); JSInterop.SetupVoid("treeviewStorage.save", _ => true);