40944a0d04
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.
222 lines
9.5 KiB
C#
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
|
|
}
|