using Microsoft.AspNetCore.Components; namespace ZB.MOM.WW.ScadaBridge.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; } // The pending dialog result is held in a typed TCS that the // host completes directly via Resolve(). The previous implementation // projected the result through Task.ContinueWith(..., TaskScheduler.Default), // which ran the projection lambda on a thread-pool thread. Completing a // strongly-typed TCS directly removes that off-render-thread hop entirely — // the awaiting caller resumes on whatever SynchronizationContext it captured // (the Blazor renderer's, for an event-handler caller). 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 Project(tcs.Task, static r => r is bool b && b); } /// 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 Project(tcs.Task, static r => r as string); } /// public Task ShowAsync(string title, RenderFragment> body, string? size = null) { EnsureNoActiveDialog(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _tcs = tcs; // The context routes Close/Cancel back through the same boxed Resolve() // path the host uses for confirm/prompt — Close boxes the typed value, // Cancel passes null. Capturing Resolve (not a service reference) keeps // the body's surface narrow and the resolution path uniform. var context = new DialogContext(Resolve); // Materialise the caller's RenderFragment> into a // plain RenderFragment bound to this context, so DialogState stays // generic-free and the host can render it without knowing TResult. var content = body(context); Current = new DialogState( title, DialogKind.Custom, Body: string.Empty, Danger: false, PromptInitial: string.Empty, Placeholder: null, Content: content, Size: size); OnChange?.Invoke(); // Project the boxed result to TResult?: a Close value unboxes to the // typed result; a Cancel (null) yields default(TResult?). The cast is // safe because only Close (with a TResult) ever boxes a non-null value. return Project(tcs.Task, static r => r is TResult typed ? typed : default); } /// /// Awaits the host's result and projects it to the caller's type. The /// await here resumes on the caller's captured context (the renderer /// sync context for an event-handler caller), not a thread-pool thread. /// private static async Task Project(Task source, Func selector) { var result = await source.ConfigureAwait(false); return selector(result); } /// /// Called by the host component (or a custom dialog's /// ) when the user dismisses or confirms /// the dialog. must be a bool for confirms, /// a string? for prompts, or the boxed TResult (null = cancel) /// for custom dialogs. /// /// The user's response: a bool for confirms, a /// string? for prompts, or the boxed custom result (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. New /// fields are appended as optional positional parameters so existing /// confirm/prompt construct calls keep compiling unchanged. /// /// Modal title text. /// Discriminates between confirm, prompt, and custom rendering. /// For confirm: the message; for prompt: the input label; ignored for custom. /// When true, the confirm button uses danger styling. /// Initial value for prompt-kind dialogs. /// Placeholder shown when the prompt input is empty. /// For : the body fragment to /// render inside .modal-body; null for confirm/prompt. /// Optional Bootstrap modal-dialog size modifier class applied /// to the modal-dialog element (e.g. modal-lg); null = default. public record DialogState( string Title, DialogKind Kind, string Body, bool Danger, string PromptInitial, string? Placeholder, RenderFragment? Content = null, string? Size = null); public enum DialogKind { Confirm, Prompt, Custom }