Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs
T
Joseph Doherty 40944a0d04 feat(centralui): row 75 — Approve is caution amber, not destructive red
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
2026-08-15 21:33:08 -04:00

222 lines
9.5 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, string? confirmLabel = null) =>
ConfirmAsync(
title,
message,
danger ? DialogSeverity.Destructive : DialogSeverity.Normal,
confirmLabel);
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(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);
}
/// <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, DialogSeverity.Normal, 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,
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);
}
/// <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="Severity">Drives the confirm button's styling, and the verb the host
/// falls back to when <paramref name="ConfirmLabel"/> is blank. Single source of truth
/// for both — there is deliberately no separate boolean, so the two cannot disagree.</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>
/// <param name="ConfirmLabel">Explicit verb for the confirm button. <c>null</c> or
/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a
/// danger confirm, "Confirm" otherwise).</param>
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
}
/// <summary>
/// How consequential the confirmed action is — drives the confirm button's colour.
/// </summary>
/// <remarks>
/// Deferred-work register row 75. The dialog surface used to carry a two-state
/// <c>bool danger</c>, 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: <em>Approve</em> and <em>Reject</em> 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.
/// <para>
/// <see cref="Caution"/> 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.
/// </para>
/// </remarks>
public enum DialogSeverity
{
/// <summary>Ordinary confirmation — renders <c>btn-primary</c>.</summary>
Normal,
/// <summary>
/// Consequential but not destructive — renders <c>btn-warning</c>. Does NOT inherit
/// the "Delete" label fallback; a caution dialog with no explicit label reads
/// "Confirm".
/// </summary>
Caution,
/// <summary>
/// Destructive or irreversible — renders <c>btn-danger</c>, and is the only severity
/// that falls back to the "Delete" verb (see <c>DialogHost.ConfirmLabel</c>).
/// </summary>
Destructive
}