9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
165 lines
7.0 KiB
C#
165 lines
7.0 KiB
C#
using Microsoft.AspNetCore.Components;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
|
|
/// <summary>
|
|
/// Default <see cref="IDialogService"/> implementation. Holds the currently
|
|
/// open dialog state in <see cref="Current"/> and notifies subscribers (the
|
|
/// <c>DialogHost</c> component) via <see cref="OnChange"/>. Only a single
|
|
/// dialog can be open at a time; attempting to open another while one is
|
|
/// already active throws <see cref="InvalidOperationException"/> — there is
|
|
/// no nested-dialog use case today and surfacing the bug is preferable to
|
|
/// silently queuing.
|
|
/// </summary>
|
|
public class DialogService : IDialogService
|
|
{
|
|
/// <summary>
|
|
/// Raised whenever <see cref="Current"/> changes (dialog opened or closed).
|
|
/// The host component subscribes and calls <c>StateHasChanged</c>.
|
|
/// </summary>
|
|
public event Action? OnChange;
|
|
|
|
/// <summary>
|
|
/// The dialog currently being displayed, or <c>null</c> when no dialog is
|
|
/// open. The host reads this to decide what (if anything) to render.
|
|
/// </summary>
|
|
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<object?>? _tcs;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
|
{
|
|
EnsureNoActiveDialog();
|
|
var tcs = new TaskCompletionSource<object?>(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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
|
{
|
|
EnsureNoActiveDialog();
|
|
var tcs = new TaskCompletionSource<object?>(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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<TResult?> ShowAsync<TResult>(string title, RenderFragment<DialogContext<TResult>> body, string? size = null)
|
|
{
|
|
EnsureNoActiveDialog();
|
|
var tcs = new TaskCompletionSource<object?>(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<TResult>(Resolve);
|
|
|
|
// Materialise the caller's RenderFragment<DialogContext<TResult>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Awaits the host's result and projects it to the caller's type. The
|
|
/// <c>await</c> here resumes on the caller's captured context (the renderer
|
|
/// sync context for an event-handler caller), not a thread-pool thread.
|
|
/// </summary>
|
|
private static async Task<TResult> Project<TResult>(Task<object?> source, Func<object?, TResult> selector)
|
|
{
|
|
var result = await source.ConfigureAwait(false);
|
|
return selector(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called by the host component (or a custom dialog's
|
|
/// <see cref="DialogContext{TResult}"/>) when the user dismisses or confirms
|
|
/// the dialog. <paramref name="result"/> must be a <c>bool</c> for confirms,
|
|
/// a <c>string?</c> for prompts, or the boxed <c>TResult</c> (null = cancel)
|
|
/// for custom dialogs.
|
|
/// </summary>
|
|
/// <param name="result">The user's response: a <c>bool</c> for confirms, a
|
|
/// <c>string?</c> for prompts, or the boxed custom result (null = cancel).</param>
|
|
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.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot of a dialog's display state, exposed read-only on
|
|
/// <see cref="DialogService.Current"/> for the host component to render. New
|
|
/// fields are appended as optional positional parameters so existing
|
|
/// confirm/prompt construct calls keep compiling unchanged.
|
|
/// </summary>
|
|
/// <param name="Title">Modal title text.</param>
|
|
/// <param name="Kind">Discriminates between confirm, prompt, and custom rendering.</param>
|
|
/// <param name="Body">For confirm: the message; for prompt: the input label; ignored for custom.</param>
|
|
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
|
|
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
|
|
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
|
|
/// <param name="Content">For <see cref="DialogKind.Custom"/>: the body fragment to
|
|
/// render inside <c>.modal-body</c>; <c>null</c> for confirm/prompt.</param>
|
|
/// <param name="Size">Optional Bootstrap modal-dialog size modifier class applied
|
|
/// to the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>); <c>null</c> = default.</param>
|
|
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
|
|
}
|