refactor(centralui): migrate TemplateEdit's four page-embedded modals to the DialogService host (M10 residual)

TemplateEdit was the last page still hand-rolling its own modal chrome after
M10 — T34c only tokenized its backdrops. All four member-authoring forms
(Attribute, Alarm, Native Alarm Source, Script) now open through
IDialogService.ShowAsync, so the single DialogHost in MainLayout owns the
backdrop, focus trap, Escape and focus restoration.

Pattern copied from the already-migrated pages (MoveDataConnectionDialog +
Templates' Move/Rename dialogs): each body is its own component beside the page
taking a DialogContext<bool>, owning its form state, rendering validation and
server errors INLINE while staying open, and closing with Close(true) only once
the save succeeded — at which point the page reloads. An inline RenderFragment
would NOT have worked: DialogHost renders the captured fragment in its own tree,
so the page's StateHasChanged could never refresh it.

Persistence deliberately stayed on the page (it owns TemplateService, the
inherited-member rules, and the repository-direct native-source path) and is
reached through an OnSaveAsync delegate returning null on success or the message
to display. Behaviour preserved verbatim, including the List-attribute encode +
Decode round-trip check, the name/trigger-type read-only-on-edit rules, the
duplicate-native-source-name guard, and NormalizeExecutionTimeout.

TemplateScriptDialog keeps all four tab panels mounted (Monaco and the JSONJoy
island must not tear down on tab switch) and hosts the Test Run panel, which
needs the live unsaved editor buffer, so it injects ScriptAnalysisService
directly and cancels an in-flight run on dispose.

Extraction moved markup that three structural source-scanning tests pinned;
all three were repointed at the new files rather than weakened:
  - TemplateNativeAlarmSourceEditorTests (+ a new test asserting the form is
    host-mounted and the body renders no chrome of its own)
  - AttributeListEditorTests (list-editor reveal now in the dialog body)
  - TestRunWarningTests (Real I/O warning travelled with the script panel)

Build 0/0; CentralUI.Tests 973/973 green.
This commit is contained in:
Joseph Doherty
2026-08-01 11:26:42 -04:00
parent fdfd5e1b27
commit a506b19d17
9 changed files with 1344 additions and 930 deletions
@@ -0,0 +1,154 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@*
M10 residual: body component for the template "Add / Edit Native Alarm Source"
dialog hosted by IDialogService.ShowAsync. Renders ONLY the form fields +
action buttons inside the host's .modal-body; the host owns the backdrop,
header, focus trap, Escape, and focus restoration.
Native alarm sources are a READ-ONLY mirror of OPC UA A&C / MxAccess Gateway
alarms, so the connection picker is restricted to alarm-capable protocols —
the page filters that list via AlarmCapableProtocols.IsAlarmCapable and hands
it in. OnSaveAsync returns null on success (dialog closes with true) or the
error to render inline (dialog stays open).
*@
<div class="row g-2">
<div class="col-12">
<label class="form-label" for="nas-name">Name</label>
<input id="nas-name" type="text" class="form-control" @bind="_nasName" readonly="@Editing" />
</div>
<div class="col-12">
<label class="form-label" for="nas-connection">Connection</label>
<select id="nas-connection" class="form-select" @bind="_nasConnection">
<option value="">— select an alarm-capable connection —</option>
@foreach (var c in AlarmCapableConnections)
{
<option value="@c.Name">@c.Name (@c.Protocol)</option>
}
</select>
@if (AlarmCapableConnections.Count == 0)
{
<div class="form-text text-warning">No OPC UA or MxGateway connections defined yet.</div>
}
</div>
<div class="col-12">
<label class="form-label" for="nas-source-ref">Source Reference</label>
<input id="nas-source-ref" type="text" class="form-control font-monospace" @bind="_nasSourceRef"
placeholder="OPC UA SourceNode nodeId, or MxAccess object/area" />
</div>
<div class="col-12">
<label class="form-label" for="nas-filter">Condition Filter <span class="text-muted">(optional)</span></label>
<input id="nas-filter" type="text" class="form-control" @bind="_nasFilter"
placeholder="Blank = mirror all conditions under the source" />
</div>
<div class="col-12">
<label class="form-label" for="nas-description">Description <span class="text-muted">(optional)</span></label>
<input id="nas-description" type="text" class="form-control" @bind="_nasDescription" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_nasIsLocked" id="nasLocked" />
<label class="form-check-label" for="nasLocked">Locked</label>
</div>
</div>
@if (_error != null)
{
<div class="col-12"><div class="text-danger small" data-test="nas-form-error">@_error</div></div>
}
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
</div>
@code {
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
/// <summary>True when editing an existing source (the name becomes read-only).</summary>
[Parameter] public bool Editing { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public string InitialConnection { get; set; } = string.Empty;
[Parameter] public string InitialSourceRef { get; set; } = string.Empty;
[Parameter] public string? InitialFilter { get; set; }
[Parameter] public string? InitialDescription { get; set; }
[Parameter] public bool InitialIsLocked { get; set; }
/// <summary>Connections whose protocol can surface native alarms (OPC UA / MxGateway).</summary>
[Parameter] public IReadOnlyList<DataConnection> AlarmCapableConnections { get; set; }
= Array.Empty<DataConnection>();
/// <summary>
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
/// <c>true</c>) or the message to render inline, leaving the dialog open.
/// </summary>
[Parameter, EditorRequired] public Func<NativeSourceDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
private string _nasName = string.Empty;
private string _nasConnection = string.Empty;
private string _nasSourceRef = string.Empty;
private string? _nasFilter;
private string? _nasDescription;
private bool _nasIsLocked;
private string? _error;
private bool _busy;
protected override void OnInitialized()
{
_nasName = InitialName;
_nasConnection = InitialConnection;
_nasSourceRef = InitialSourceRef;
_nasFilter = InitialFilter;
_nasDescription = InitialDescription;
_nasIsLocked = InitialIsLocked;
}
private async Task Submit()
{
if (_busy) return;
_error = null;
_busy = true;
try
{
var draft = new NativeSourceDraft(
_nasName,
_nasConnection,
_nasSourceRef,
_nasFilter,
_nasDescription,
_nasIsLocked);
var error = await OnSaveAsync(draft);
if (error == null)
{
Context.Close(true);
}
else
{
_error = error;
}
}
finally
{
_busy = false;
}
}
/// <summary>The authored native alarm source as submitted; the page performs the add/update.</summary>
/// <param name="Name">Source name (ignored on edit — the stored name is fixed).</param>
/// <param name="ConnectionName">Alarm-capable data connection to mirror from.</param>
/// <param name="SourceReference">OPC UA SourceNode nodeId, or MxAccess object/area.</param>
/// <param name="ConditionFilter">Optional condition filter; blank mirrors every condition.</param>
/// <param name="Description">Optional free-text description.</param>
/// <param name="IsLocked">Whether instances may override the binding.</param>
public sealed record NativeSourceDraft(
string Name,
string ConnectionName,
string SourceReference,
string? ConditionFilter,
string? Description,
bool IsLocked);
}