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, string? confirmLabel = null) =>
ConfirmAsync(
title,
message,
danger ? DialogSeverity.Destructive : DialogSeverity.Normal,
confirmLabel);
///
public Task ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(
title,
DialogKind.Confirm,
message,
severity,
PromptInitial: string.Empty,
Placeholder: null,
ConfirmLabel: confirmLabel);
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, DialogSeverity.Normal, 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,
DialogSeverity.Normal,
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.
/// Drives the confirm button's styling, and the verb the host
/// falls back to when is blank. Single source of truth
/// for both — there is deliberately no separate boolean, so the two cannot disagree.
/// 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.
/// Explicit verb for the confirm button. null or
/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a
/// danger confirm, "Confirm" otherwise).
public record DialogState(
string Title,
DialogKind Kind,
string Body,
DialogSeverity Severity,
string PromptInitial,
string? Placeholder,
RenderFragment? Content = null,
string? Size = null,
string? ConfirmLabel = null);
public enum DialogKind
{
Confirm,
Prompt,
Custom
}
///
/// How consequential the confirmed action is — drives the confirm button's colour.
///
///
/// Deferred-work register row 75. The dialog surface used to carry a two-state
/// bool danger , which forced every consequential-but-not-destructive action to
/// choose between looking like a delete and looking like the safe default. The Secured
/// Writes page is where that bit them: Approve and Reject both rendered
/// as identical red buttons, so at the moment of a two-person-control decision the colour
/// carried no discriminating information at all — and the affirmative half of the control
/// was painted with the delete affordance.
///
/// is the middle rung that was missing. It is for actions that are
/// consequential and worth a beat of hesitation, but are not destroying anything: the
/// canonical case is a write to live plant equipment.
///
///
public enum DialogSeverity
{
/// Ordinary confirmation — renders btn-primary .
Normal,
///
/// Consequential but not destructive — renders btn-warning . Does NOT inherit
/// the "Delete" label fallback; a caution dialog with no explicit label reads
/// "Confirm".
///
Caution,
///
/// Destructive or irreversible — renders btn-danger , and is the only severity
/// that falls back to the "Delete" verb (see DialogHost.ConfirmLabel ).
///
Destructive
}