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,132 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@*
M10 residual: body component for the template "Add / Edit Alarm" 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.
Same contract as TemplateAttributeDialog: OnSaveAsync returns null on success
(dialog closes with true) or the error to render inline (dialog stays open).
*@
<div class="row g-3">
<div class="col-12">
<label class="form-label" for="alarm-name">Name</label>
<input id="alarm-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
</div>
<div class="col-12">
<label class="form-label" for="alarm-trigger-type">Trigger Type</label>
<select id="alarm-trigger-type" class="form-select" @bind="_triggerType" disabled="@Editing">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label" for="alarm-priority">Priority</label>
<input id="alarm-priority" type="number" class="form-control" @bind="_priority" min="0" max="1000" />
</div>
<div class="col-12">
<label class="form-label">Trigger Configuration</label>
<AlarmTriggerEditor TriggerType="@_triggerType"
Value="@_triggerConfig"
ValueChanged="@(v => _triggerConfig = v)"
AvailableAttributes="@AvailableAttributes"
FallbackPriority="@_priority" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="alarmLocked" />
<label class="form-check-label" for="alarmLocked">Locked</label>
</div>
</div>
@if (_error != null)
{
<div class="col-12"><div class="text-danger small" data-test="alarm-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 alarm (name + trigger type become read-only).</summary>
[Parameter] public bool Editing { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public int InitialPriority { get; set; }
[Parameter] public AlarmTriggerType InitialTriggerType { get; set; }
[Parameter] public string? InitialTriggerConfig { get; set; }
[Parameter] public bool InitialIsLocked { get; set; }
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
= Array.Empty<AlarmAttributeChoice>();
/// <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<AlarmDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
private string _name = string.Empty;
private int _priority;
private AlarmTriggerType _triggerType;
private string? _triggerConfig;
private bool _isLocked;
private string? _error;
private bool _busy;
protected override void OnInitialized()
{
_name = InitialName;
_priority = InitialPriority;
_triggerType = InitialTriggerType;
_triggerConfig = InitialTriggerConfig;
_isLocked = InitialIsLocked;
}
private async Task Submit()
{
if (_busy) return;
_error = null;
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
_busy = true;
try
{
var error = await OnSaveAsync(new AlarmDraft(_name, _triggerType, _priority, _triggerConfig, _isLocked));
if (error == null)
{
Context.Close(true);
}
else
{
_error = error;
}
}
finally
{
_busy = false;
}
}
/// <summary>The authored alarm as submitted; the page performs the add/update.</summary>
/// <param name="Name">Alarm name (ignored on edit — the stored name is fixed).</param>
/// <param name="TriggerType">Selected trigger type (ignored on edit — server-fixed).</param>
/// <param name="Priority">Priority level.</param>
/// <param name="TriggerConfig">Structured trigger configuration payload.</param>
/// <param name="IsLocked">Whether instances may override the alarm.</param>
public sealed record AlarmDraft(
string Name,
AlarmTriggerType TriggerType,
int Priority,
string? TriggerConfig,
bool IsLocked);
}
@@ -0,0 +1,188 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@*
M10 residual: body component for the template "Add / Edit Attribute" 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.
Mirrors MoveDataConnectionDialog: the save round-trip runs from inside the
body, a failure is shown INLINE and the dialog STAYS OPEN, and success closes
with Close(true) so the page toasts and reloads. The actual persistence stays
on the page (TemplateEdit owns TemplateService + the inherited-member rules)
and is reached through the OnSaveAsync delegate, which returns null on success
or the error message to display.
*@
<div class="row g-3">
<div class="col-12">
<label class="form-label" for="attr-name">Name</label>
<input id="attr-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
</div>
<div class="col-12">
<label class="form-label" for="attr-data-type">Data Type</label>
<select id="attr-data-type" class="form-select" value="@_dataType" @onchange="OnDataTypeChanged" disabled="@Editing">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
@if (Editing)
{
<div class="form-text">Data type is fixed once the attribute is created.</div>
}
</div>
@if (_dataType == DataType.List)
{
<div class="col-12">
@* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@
<AttributeListEditor @bind-ElementDataType="_elementDataType"
@bind-Rows="_listRows"
ShowElementType="@(!Editing)"
Disabled="false" />
</div>
}
else
{
<div class="col-12">
<label class="form-label" for="attr-value">Value</label>
<input id="attr-value" type="text" class="form-control" @bind="_value" />
</div>
}
<div class="col-12">
<label class="form-label" for="attr-data-source-ref">Data Source Ref</label>
<input id="attr-data-source-ref" type="text" class="form-control" @bind="_dataSourceRef" placeholder="Tag path" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="attrLocked" />
<label class="form-check-label" for="attrLocked">Locked</label>
</div>
</div>
@if (_error != null)
{
<div class="col-12"><div class="text-danger small" data-test="attr-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 attribute (name + data type become read-only).</summary>
[Parameter] public bool Editing { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public string? InitialValue { get; set; }
[Parameter] public DataType InitialDataType { get; set; }
[Parameter] public DataType InitialElementDataType { get; set; } = DataType.String;
[Parameter] public IReadOnlyList<string> InitialListRows { get; set; } = Array.Empty<string>();
[Parameter] public bool InitialIsLocked { get; set; }
[Parameter] public string? InitialDataSourceRef { get; set; }
/// <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<AttributeDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
private string _name = string.Empty;
private string? _value;
private DataType _dataType;
private DataType _elementDataType = DataType.String;
private List<string> _listRows = new();
private bool _isLocked;
private string? _dataSourceRef;
private string? _error;
private bool _busy;
protected override void OnInitialized()
{
_name = InitialName;
_value = InitialValue;
_dataType = InitialDataType;
_elementDataType = InitialElementDataType;
_listRows = InitialListRows.ToList();
_isLocked = InitialIsLocked;
_dataSourceRef = InitialDataSourceRef;
}
// Switching the data type clears stale list state so a List ⇄ scalar
// toggle never carries the other mode's value into the submit.
private void OnDataTypeChanged(ChangeEventArgs e)
{
if (!Enum.TryParse<DataType>((string?)e.Value, out var dt) || dt == _dataType) return;
_dataType = dt;
if (dt == DataType.List)
{
_value = null;
_listRows = new();
if (!AttributeValueCodec.IsValidElementType(_elementDataType))
_elementDataType = DataType.String;
}
else
{
_listRows = new();
}
}
private async Task Submit()
{
if (_busy) return;
_error = null;
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
_busy = true;
try
{
var draft = new AttributeDraft(
_name,
_dataType,
_elementDataType,
_listRows.ToList(),
_value,
_dataSourceRef,
_isLocked);
var error = await OnSaveAsync(draft);
if (error == null)
{
Context.Close(true);
}
else
{
_error = error;
}
}
finally
{
_busy = false;
}
}
/// <summary>
/// The authored attribute as submitted. The page resolves the stored value
/// (list encoding + validation for <see cref="DataType.List"/>) and performs
/// the add/update, so this record carries the raw form state only.
/// </summary>
/// <param name="Name">Attribute name (ignored on edit — the stored name is fixed).</param>
/// <param name="DataType">Selected data type (fixed on edit).</param>
/// <param name="ElementDataType">Element scalar type; meaningful only for a List attribute.</param>
/// <param name="ListRows">Per-element string rows; meaningful only for a List attribute.</param>
/// <param name="Value">Scalar value; meaningful only for a non-List attribute.</param>
/// <param name="DataSourceRef">Tag path binding, or null.</param>
/// <param name="IsLocked">Whether instances may override the attribute.</param>
public sealed record AttributeDraft(
string Name,
DataType DataType,
DataType ElementDataType,
List<string> ListRows,
string? Value,
string? DataSourceRef,
bool IsLocked);
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -0,0 +1,492 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@inject ScriptAnalysisService AnalysisService
@*
M10 residual: body component for the template "Add / Edit Script" dialog hosted
by IDialogService.ShowAsync. Renders ONLY the tabbed authoring surface, the
Test Run panel, and the action buttons inside the host's .modal-body; the host
owns the backdrop, header, focus trap, Escape, and focus restoration.
All four tab panels stay mounted (toggled via display:none) so the Monaco
editors and the JSONJoy React island don't tear down on tab switch — that is
why the whole form lives in ONE component rather than per-tab bodies.
The Test Run panel runs here (it needs the live, unsaved editor buffer), so
this component injects ScriptAnalysisService directly. Persistence stays on the
page and is reached through OnSaveAsync, which returns null on success (the
dialog closes with true) or the error to render inline (dialog stays open).
*@
@implements IDisposable
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label" for="script-name">Name</label>
<input id="script-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="scriptLocked" />
<label class="form-check-label" for="scriptLocked">Locked</label>
</div>
</div>
</div>
@* Tabs: Trigger, Code, Parameters, Return. All panels stay
mounted (toggled via display:none) so Monaco editors and the
JSONJoy React island don't tear down on tab switch. *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_tab == "trigger" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "trigger" ? "true" : "false")"
@onclick='() => _tab = "trigger"'>Trigger</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_tab == "code" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "code" ? "true" : "false")"
@onclick='() => _tab = "code"'>Code</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_tab == "parameters" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "parameters" ? "true" : "false")"
@onclick='() => _tab = "parameters"'>Parameters</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_tab == "return" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "return" ? "true" : "false")"
@onclick='() => _tab = "return"'>Return type</button>
</li>
</ul>
<div class="border border-top-0 rounded-bottom p-3">
<div style="display: @(_tab == "trigger" ? "block" : "none")">
<ScriptTriggerEditor TriggerType="@_triggerType"
TriggerConfig="@_triggerConfig"
Changed="@OnTriggerChanged"
AvailableAttributes="@AvailableAttributes" />
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_triggerType))
{
<div class="mt-3">
<label class="form-label">Min time between runs</label>
<div class="row g-2" style="max-width: 420px;">
<div class="col-7">
<input type="number" min="1" step="1" class="form-control"
placeholder="(optional)"
@bind="_minTimeValue" @bind:event="oninput" />
</div>
<div class="col-5">
<select class="form-select" @bind="_minTimeUnit">
<option value="ms">milliseconds</option>
<option value="sec">seconds</option>
<option value="min">minutes</option>
</select>
</div>
</div>
@if (TriggerIsWhileTrue())
{
<div class="form-text">
This is the re-fire interval for the
<strong>WhileTrue</strong> trigger above.
</div>
@if (DurationInput.Compose(_minTimeValue, _minTimeUnit) is null)
{
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
The WhileTrue trigger has no interval set — the script
will fire only once. Set a value here to make it re-fire.
</div>
}
}
else
{
<div class="form-text">
Optional throttle — skips trigger invocations that fire
sooner than this.
</div>
}
</div>
}
<div class="mt-3">
<label class="form-label">Execution timeout</label>
<div class="input-group" style="max-width: 280px;">
<input type="number" min="1" step="1" class="form-control"
placeholder="(site default)"
@bind="_executionTimeoutSeconds" @bind:event="oninput" />
<span class="input-group-text">seconds</span>
</div>
<div class="form-text">
Per-script execution timeout. Leave blank (or 0) to use the
site's global default.
</div>
</div>
</div>
<div style="display: @(_tab == "code" ? "block" : "none")">
<MonacoEditor @ref="_editor" Value="@_code" ValueChanged="@(v => _code = v)"
Language="csharp" Height="360px"
DeclaredParameters="@ScriptParameterNames.Parse(_parameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_parameters)"
SiblingScripts="@SiblingScripts"
SelfAttributes="@SelfAttributes"
Children="@EditorChildren"
Parent="@EditorParent"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
<div style="display: @(_tab == "parameters" ? "block" : "none")">
<SchemaBuilder Mode="object"
Value="@_parameters"
ValueChanged="@(v => _parameters = v)" />
</div>
<div style="display: @(_tab == "return" ? "block" : "none")">
<SchemaBuilder Mode="value"
Value="@_return"
ValueChanged="@(v => _return = v)" />
</div>
</div>
@if (_error != null)
{
<div class="text-danger small mt-2" data-test="script-form-error">@_error</div>
}
@if (_showTestRun)
{
<div class="card mt-3" id="script-test-run-panel">
<div class="card-header py-2">
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
</div>
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
<strong>Heads up:</strong>
runs the script as typed (unsaved edits included) against the supplied
<code>Parameters</code>.
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
@if (DeployedInstances.Count == 0)
{
<div class="form-text">
No running instances of this template.
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
</div>
}
else
{
<select class="form-select form-select-sm" @bind="_bindInstance">
<option value="">— None (Instance/Attributes throw) —</option>
@foreach (var inst in DeployedInstances)
{
<option value="@inst.UniqueName">@inst.UniqueName</option>
}
</select>
<div class="form-text">
Routes <code>Instance.GetAttribute/SetAttribute</code>,
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
<code>CallScript</code> to the selected live instance.
</div>
}
</div>
<div class="mb-3">
<label class="form-label small">Parameter values</label>
<ParameterValueForm ParameterDefinitions="@_parameters"
Values="_paramValues"
ValuesChanged="@(v => _paramValues = v)" />
</div>
<div class="d-flex gap-2 align-items-center mb-3">
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
@if (_running)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Running…</span>
}
else
{
<span>Run</span>
}
</button>
@if (_runResult != null)
{
<span class="text-muted small">@_runResult.DurationMs ms</span>
}
</div>
@if (_runResult != null)
{
@if (_runResult.Success)
{
<div class="mb-3">
<label class="form-label small text-success mb-1">
Return value <span class="badge bg-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
</label>
<pre class="bg-body-secondary border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
</div>
}
else
{
<div class="mb-3">
<label class="form-label small text-danger mb-1">
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_runResult.ErrorKind)</span>
</label>
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
@if (_runResult.Markers is { Count: > 0 })
{
<ul class="small text-danger mt-2 mb-0">
@foreach (var m in _runResult.Markers)
{
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
}
</ul>
}
</div>
}
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
{
<div class="mb-0">
<label class="form-label small mb-1">Console output</label>
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
</div>
}
}
</div>
</div>
}
<div class="modal-footer px-0 pb-0 mt-3">
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="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 script (the name becomes read-only).</summary>
[Parameter] public bool Editing { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public string InitialCode { get; set; } = string.Empty;
[Parameter] public string? InitialTriggerType { get; set; }
[Parameter] public string? InitialTriggerConfig { get; set; }
[Parameter] public TimeSpan? InitialMinTimeBetweenRuns { get; set; }
[Parameter] public string? InitialParameters { get; set; }
[Parameter] public string? InitialReturn { get; set; }
[Parameter] public bool InitialIsLocked { get; set; }
[Parameter] public int? InitialExecutionTimeoutSeconds { get; set; }
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
= Array.Empty<AlarmAttributeChoice>();
/// <summary>Sibling scripts on the same template, for <c>CallScript</c> completion.</summary>
[Parameter] public IReadOnlyList<ScriptShape>? SiblingScripts { get; set; }
/// <summary>This template's own attributes, for <c>Attributes.*</c> completion.</summary>
[Parameter] public IReadOnlyList<AttributeShape>? SelfAttributes { get; set; }
/// <summary>Composed children's contexts, for <c>Children.*</c> completion.</summary>
[Parameter] public IReadOnlyList<CompositionContext>? EditorChildren { get; set; }
/// <summary>The slot-owner context, for <c>Parent.*</c> completion; null for a base template.</summary>
[Parameter] public CompositionContext? EditorParent { get; set; }
/// <summary>Running instances of this template, offered as Test Run bind targets.</summary>
[Parameter] public IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance> DeployedInstances { get; set; }
= Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance>();
/// <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<ScriptDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
private string _name = string.Empty;
private string _code = string.Empty;
private string? _triggerType;
private string? _triggerConfig;
private string? _minTimeValue;
private string _minTimeUnit = "sec";
private string? _parameters;
private string? _return;
private bool _isLocked;
private int? _executionTimeoutSeconds;
private string? _error;
private bool _busy;
private string _tab = "trigger"; // "trigger" | "code" | "parameters" | "return"
private MonacoEditor? _editor;
private IReadOnlyList<DiagnosticMarker> _markers = Array.Empty<DiagnosticMarker>();
// Test Run state.
private bool _showTestRun;
private bool _running;
private Dictionary<string, object?> _paramValues = new();
private SandboxRunResult? _runResult;
private CancellationTokenSource? _runCts;
private string _bindInstance = string.Empty;
protected override void OnInitialized()
{
_name = InitialName;
_code = InitialCode;
_triggerType = InitialTriggerType;
_triggerConfig = InitialTriggerConfig;
(_minTimeValue, _minTimeUnit) = DurationInput.Split(InitialMinTimeBetweenRuns);
_parameters = InitialParameters;
_return = InitialReturn;
_isLocked = InitialIsLocked;
_executionTimeoutSeconds = InitialExecutionTimeoutSeconds;
}
/// <summary>Cancels an in-flight Test Run when the dialog is torn down.</summary>
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
_runCts = null;
}
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
private void OnTriggerChanged(ScriptTriggerValue v)
{
_triggerType = v.TriggerType;
_triggerConfig = v.Config;
}
/// <summary>
/// True when the current script trigger is a WhileTrue Conditional/Expression
/// trigger — the case where the "Min time between runs" interval is required
/// (it is the re-fire cadence).
/// </summary>
private bool TriggerIsWhileTrue()
{
var kind = ScriptTriggerConfigCodec.ParseKind(_triggerType);
return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
&& ScriptTriggerConfigCodec.Parse(_triggerConfig, kind).Mode
== ScriptTriggerMode.WhileTrue;
}
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
private void Cancel()
{
Dispose();
Context.Cancel();
}
private async Task RunInSandboxAsync()
{
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
_running = true;
_runResult = null;
StateHasChanged();
try
{
var jsonParams = _paramValues.ToDictionary(
kv => kv.Key,
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
var request = new SandboxRunRequest(
_code, jsonParams, TimeoutSeconds: null,
BindInstanceUniqueName: string.IsNullOrEmpty(_bindInstance) ? null : _bindInstance);
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
}
catch (OperationCanceledException) { /* superseded by next Run click */ }
catch (Exception ex)
{
_runResult = new SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
ErrorKind: SandboxErrorKind.RuntimeError,
DurationMs: 0,
Markers: null);
}
finally
{
_running = false;
StateHasChanged();
}
}
private static string ScriptErrorKindLabel(SandboxErrorKind kind) => kind switch
{
SandboxErrorKind.CompileError => "Compile error",
SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
SandboxErrorKind.RuntimeError => "Runtime error",
SandboxErrorKind.Timeout => "Timeout",
_ => "Error"
};
private async Task Submit()
{
if (_busy) return;
_error = null;
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_code)) { _error = "Code is required."; return; }
_busy = true;
try
{
var draft = new ScriptDraft(
_name,
_code,
_triggerType,
_triggerConfig,
DurationInput.Compose(_minTimeValue, _minTimeUnit),
_parameters,
_return,
_isLocked,
_executionTimeoutSeconds);
var error = await OnSaveAsync(draft);
if (error == null)
{
Dispose();
Context.Close(true);
}
else
{
_error = error;
}
}
finally
{
_busy = false;
}
}
/// <summary>The authored script as submitted; the page performs the add/update.</summary>
/// <param name="Name">Script name (ignored on edit — the stored name is fixed).</param>
/// <param name="Code">Script body as typed.</param>
/// <param name="TriggerType">Structured trigger type token, or null.</param>
/// <param name="TriggerConfig">Structured trigger configuration payload, or null.</param>
/// <param name="MinTimeBetweenRuns">Composed throttle / re-fire interval, or null.</param>
/// <param name="ParameterDefinitions">Parameter schema JSON, or null.</param>
/// <param name="ReturnDefinition">Return-type schema JSON, or null.</param>
/// <param name="IsLocked">Whether instances may override the script.</param>
/// <param name="ExecutionTimeoutSeconds">Raw timeout input; the page normalizes ≤0 to null.</param>
public sealed record ScriptDraft(
string Name,
string Code,
string? TriggerType,
string? TriggerConfig,
TimeSpan? MinTimeBetweenRuns,
string? ParameterDefinitions,
string? ReturnDefinition,
bool IsLocked,
int? ExecutionTimeoutSeconds);
}