From a506b19d1724618d47fe3267bbebe638c798fdbd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 11:26:42 -0400 Subject: [PATCH] refactor(centralui): migrate TemplateEdit's four page-embedded modals to the DialogService host (M10 residual) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- docs/requirements/Component-CentralUI.md | 5 +- .../Pages/Design/TemplateAlarmDialog.razor | 132 ++ .../Design/TemplateAttributeDialog.razor | 188 +++ .../Pages/Design/TemplateEdit.razor | 1205 +++++------------ .../TemplateNativeAlarmSourceDialog.razor | 154 +++ .../Pages/Design/TemplateScriptDialog.razor | 492 +++++++ .../Design/AttributeListEditorTests.cs | 33 +- .../TemplateNativeAlarmSourceEditorTests.cs | 60 +- .../Design/TestRunWarningTests.cs | 5 +- 9 files changed, 1344 insertions(+), 930 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAlarmDialog.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAttributeDialog.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateNativeAlarmSourceDialog.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateScriptDialog.razor diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index e94f6b9d..0861caf2 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -47,7 +47,8 @@ Central cluster only. Sites have no user interface. - Manage template hierarchy (inheritance) — visual tree of parent/child relationships. - Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names. - Define and edit attributes, alarms, and scripts on templates. -- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **modal**: +- **Member authoring dialogs are hosted, not page-embedded** (M10 residual, 2026-08-01): the template editor's Attribute, Alarm, Native Alarm Source, and Script forms all open through `IDialogService.ShowAsync`, so the single `DialogHost` in `MainLayout` owns the backdrop, focus trap, Escape, and focus restoration. Each form body is its own component beside the page (`TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog`), following the `MoveDataConnectionDialog` pattern — the body renders validation and server errors **inline and stays open**, closing only on a successful save; persistence stays on the page (which owns `TemplateService`) behind an `OnSaveAsync` delegate. +- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **hosted dialog** (`TemplateNativeAlarmSourceDialog`): - **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`). - **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols). - **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area). @@ -158,7 +159,7 @@ Central cluster only. Sites have no user interface. #### Tabbed Layout -The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView` component. +The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView` component. As of 2026-08-01 that component implements the full **WAI-ARIA tree keyboard pattern** — roving tabindex (one Tab stop per tree), Arrow/Home/End movement, Enter/Space activation, plus `aria-level`/`aria-posinset`/`aria-setsize` — so every tree surface in the Central UI (Debug View, Data Connections, Topology, the template folder browser) is keyboard-navigable. See [`docs/components/TreeView.md`](../components/TreeView.md). **Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAlarmDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAlarmDialog.razor new file mode 100644 index 00000000..51b2f2bc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAlarmDialog.razor @@ -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). +*@ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ @if (_error != null) + { +
@_error
+ } +
+ + + +@code { + /// Host-supplied context: Close(true) on a successful save, Cancel on dismiss. + [Parameter, EditorRequired] public DialogContext Context { get; set; } = default!; + + /// True when editing an existing alarm (name + trigger type become read-only). + [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; } + + /// Attribute choices offered by the structured trigger editor's picker. + [Parameter] public IReadOnlyList AvailableAttributes { get; set; } + = Array.Empty(); + + /// + /// Persists the draft. Returns null on success (the dialog closes with + /// true) or the message to render inline, leaving the dialog open. + /// + [Parameter, EditorRequired] public Func> 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; + } + } + + /// The authored alarm as submitted; the page performs the add/update. + /// Alarm name (ignored on edit — the stored name is fixed). + /// Selected trigger type (ignored on edit — server-fixed). + /// Priority level. + /// Structured trigger configuration payload. + /// Whether instances may override the alarm. + public sealed record AlarmDraft( + string Name, + AlarmTriggerType TriggerType, + int Priority, + string? TriggerConfig, + bool IsLocked); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAttributeDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAttributeDialog.razor new file mode 100644 index 00000000..51c739ce --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAttributeDialog.razor @@ -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. +*@ +
+
+ + +
+
+ + + @if (Editing) + { +
Data type is fixed once the attribute is created.
+ } +
+ @if (_dataType == DataType.List) + { +
+ @* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@ + +
+ } + else + { +
+ + +
+ } +
+ + +
+
+
+ + +
+
+ @if (_error != null) + { +
@_error
+ } +
+ + + +@code { + /// Host-supplied context: Close(true) on a successful save, Cancel on dismiss. + [Parameter, EditorRequired] public DialogContext Context { get; set; } = default!; + + /// True when editing an existing attribute (name + data type become read-only). + [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 InitialListRows { get; set; } = Array.Empty(); + [Parameter] public bool InitialIsLocked { get; set; } + [Parameter] public string? InitialDataSourceRef { get; set; } + + /// + /// Persists the draft. Returns null on success (the dialog closes with + /// true) or the message to render inline, leaving the dialog open. + /// + [Parameter, EditorRequired] public Func> OnSaveAsync { get; set; } = default!; + + private string _name = string.Empty; + private string? _value; + private DataType _dataType; + private DataType _elementDataType = DataType.String; + private List _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((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; + } + } + + /// + /// The authored attribute as submitted. The page resolves the stored value + /// (list encoding + validation for ) and performs + /// the add/update, so this record carries the raw form state only. + /// + /// Attribute name (ignored on edit — the stored name is fixed). + /// Selected data type (fixed on edit). + /// Element scalar type; meaningful only for a List attribute. + /// Per-element string rows; meaningful only for a List attribute. + /// Scalar value; meaningful only for a non-List attribute. + /// Tag path binding, or null. + /// Whether instances may override the attribute. + public sealed record AttributeDraft( + string Name, + DataType DataType, + DataType ElementDataType, + List ListRows, + string? Value, + string? DataSourceRef, + bool IsLocked); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor index 1d35d9c2..36e35335 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -83,72 +83,20 @@ private bool _validating; private Commons.Types.Flattening.ValidationResult? _validationResult; - // Member add/edit forms. _edit*Id null = adding; non-null = editing that row. - private bool _showAttrForm; - private int? _editAttrId; - private string _attrName = string.Empty; - private string? _attrValue; - private DataType _attrDataType; - // List-attribute authoring state (DataType.List only): the element scalar - // type + the per-element string rows. Encoded to canonical JSON on submit. - private DataType _attrElementDataType = DataType.String; - private List _attrListRows = new(); - private bool _attrIsLocked; - private string? _attrDataSourceRef; - private string? _attrFormError; - - private bool _showAlarmForm; - private int? _editAlarmId; - private string _alarmName = string.Empty; - private int _alarmPriority; - private AlarmTriggerType _alarmTriggerType; - private string? _alarmTriggerConfig; - private bool _alarmIsLocked; - private string? _alarmFormError; + // Member add/edit forms are IDialogService.ShowAsync dialogs (M10 residual — + // migrated off the page-embedded modals). Each body component owns its own + // form state; the page keeps only the data those dialogs read and the save + // delegates they call back into. // Native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms) private List _nativeSources = new(); private List _alarmCapableConnections = new(); - private bool _showNativeSourceForm; - private int? _editNativeSourceId; - 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? _nasFormError; - private bool _showScriptForm; - private int? _editScriptId; - private string _scriptName = string.Empty; - private string _scriptCode = string.Empty; - private string? _scriptTriggerType; - private string? _scriptTriggerConfig; - private string? _scriptMinTimeValue; - private string _scriptMinTimeUnit = "sec"; - private string? _scriptParameters; - private string? _scriptReturn; - private bool _scriptIsLocked; - // Per-script execution-timeout override (seconds). Bound to the "Execution timeout" - // input on the trigger tab; null/0 means "use the site's global default" (#54). - private int? _scriptExecutionTimeoutSeconds; - private string? _scriptFormError; - private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return" - private MonacoEditor? _scriptEditor; - private IReadOnlyList _scriptMarkers - = Array.Empty(); private IReadOnlyList _editorChildren = Array.Empty(); - // Script modal Test Run state. - private bool _showScriptTestRun; - private bool _scriptRunning; - private Dictionary _scriptParamValues = new(); - private ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxRunResult? _scriptRunResult; - private CancellationTokenSource? _scriptRunCts; + // Test Run bind targets offered by the script dialog. private List _deployedInstances = new(); - private string _scriptBindInstance = string.Empty; /// /// Editor's Parent.* context. Empty for base templates (no owner exists); @@ -700,80 +648,9 @@ {
Attributes
- +
- @if (_showAttrForm) - { - var editing = _editAttrId.HasValue; - - } - var derived = _selectedTemplate!.IsDerived; @@ -872,7 +749,7 @@ @if (!(derived && baseAttr != null)) {
  • - +
  • @@ -992,67 +869,9 @@ {
    Alarms
    - +
    - @if (_showAlarmForm) - { - var editing = _editAlarmId.HasValue; - - } -
  • @@ -1090,7 +909,7 @@ aria-label="@($"More actions for {alarm.Name}")">⋮
    @@ -1689,7 +1189,7 @@ @if (!(derivedScripts && baseScript != null)) {
  • - +
  • @@ -1753,58 +1253,47 @@ // ---- CRUD handlers ---- - private void BeginAddAttribute() - { - _showAttrForm = true; - _editAttrId = null; - _attrFormError = null; - _attrName = string.Empty; - _attrValue = null; - _attrDataType = default; - _attrElementDataType = DataType.String; - _attrListRows = new(); - _attrIsLocked = false; - _attrDataSourceRef = null; - } + // Attribute add/edit dialog: opened via IDialogService.ShowAsync. The body + // owns the form state and renders validation/server errors INLINE, staying + // open; it closes with true only once the save succeeded, at which point the + // page reloads. Persistence stays here — TemplateEdit owns TemplateService and + // the inherited-member rules — and is reached through the OnSaveAsync delegate. + private Task OpenAddAttributeDialog() + => ShowAttributeDialogAsync( + editId: null, + new TemplateAttributeDialog.AttributeDraft( + string.Empty, default, DataType.String, new(), null, null, false)); - private void BeginEditAttribute(TemplateAttribute attr) - { - _showAttrForm = true; - _editAttrId = attr.Id; - _attrFormError = null; - _attrName = attr.Name; - _attrValue = attr.Value; - _attrDataType = attr.DataType; - _attrElementDataType = attr.ElementDataType ?? DataType.String; - _attrListRows = DecodeListRows(attr.Value, attr.ElementDataType); - _attrIsLocked = attr.IsLocked; - _attrDataSourceRef = attr.DataSourceReference; - } + private Task OpenEditAttributeDialog(TemplateAttribute attr) + => ShowAttributeDialogAsync( + attr.Id, + new TemplateAttributeDialog.AttributeDraft( + attr.Name, + attr.DataType, + attr.ElementDataType ?? DataType.String, + DecodeListRows(attr.Value, attr.ElementDataType), + attr.Value, + attr.DataSourceReference, + attr.IsLocked)); - private void CancelAttributeForm() + private async Task ShowAttributeDialogAsync(int? editId, TemplateAttributeDialog.AttributeDraft initial) { - _showAttrForm = false; - _editAttrId = null; - _attrFormError = null; - } + var editing = editId.HasValue; + var saved = await Dialog.ShowAsync( + editing ? "Edit Attribute" : "Add Attribute", + ctx => @, + size: "modal-dialog-scrollable"); - // 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 OnAttrDataTypeChanged(ChangeEventArgs e) - { - if (!Enum.TryParse((string?)e.Value, out var dt) || dt == _attrDataType) return; - _attrDataType = dt; - if (dt == DataType.List) - { - _attrValue = null; - _attrListRows = new(); - if (!AttributeValueCodec.IsValidElementType(_attrElementDataType)) - _attrElementDataType = DataType.String; - } - else - { - _attrListRows = new(); - } + if (saved) await LoadAsync(); } // Decodes a stored List JSON value into editable string rows. A malformed @@ -1828,84 +1317,74 @@ return new(); } - private async Task SaveAttribute() + /// + /// Persists an attribute draft submitted from TemplateAttributeDialog. + /// Returns null on success (the dialog closes and the page reloads) or + /// the message the dialog renders inline while staying open. + /// + private async Task SaveAttributeDraftAsync(int? editId, TemplateAttributeDialog.AttributeDraft draft) { - if (_selectedTemplate == null) return; - _attrFormError = null; - if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; } + if (_selectedTemplate == null) return "Template is not loaded."; + if (string.IsNullOrWhiteSpace(draft.Name)) return "Name is required."; // Resolve the value + element type per data type. List attributes encode // their rows to canonical JSON and validate them locally before submit // (TemplateService persists directly and does not list-validate). string? attrValue; DataType? elementType; - if (_attrDataType == DataType.List) + if (draft.DataType == DataType.List) { - elementType = _attrElementDataType; - attrValue = AttributeValueCodec.Encode(_attrListRows); + elementType = draft.ElementDataType; + attrValue = AttributeValueCodec.Encode(draft.ListRows); // Round-trip through Decode to surface any un-parseable element // (e.g. non-numeric in an Int32 list) before hitting the server. try { AttributeValueCodec.Decode(attrValue, DataType.List, elementType); } - catch (FormatException ex) { _attrFormError = ex.Message; return; } + catch (FormatException ex) { return ex.Message; } } else { elementType = null; - attrValue = _attrValue?.Trim(); + attrValue = draft.Value?.Trim(); } var user = await GetCurrentUserAsync(); - if (_editAttrId is int id) + if (editId is int id) { var existing = _attributes.FirstOrDefault(a => a.Id == id); - if (existing == null) { _attrFormError = "Attribute no longer exists."; return; } + if (existing == null) return "Attribute no longer exists."; var proposed = new TemplateAttribute(existing.Name) { - DataType = _attrDataType, + DataType = draft.DataType, ElementDataType = elementType, Value = attrValue, - IsLocked = _attrIsLocked, - DataSourceReference = _attrDataSourceRef?.Trim(), + IsLocked = draft.IsLocked, + DataSourceReference = draft.DataSourceRef?.Trim(), Description = existing.Description, IsInherited = existing.IsInherited, LockedInDerived = existing.LockedInDerived, }; var result = await TemplateService.UpdateAttributeAsync(id, proposed, user); - if (result.IsSuccess) - { - _showAttrForm = false; - _editAttrId = null; - _toast.ShowSuccess($"Attribute '{existing.Name}' updated."); - await LoadAsync(); - } - else - { - _attrFormError = result.Error; - } - return; + if (!result.IsSuccess) return result.Error; + + _toast.ShowSuccess($"Attribute '{existing.Name}' updated."); + return null; } - var attr = new TemplateAttribute(_attrName.Trim()) + var attr = new TemplateAttribute(draft.Name.Trim()) { - DataType = _attrDataType, + DataType = draft.DataType, ElementDataType = elementType, Value = attrValue, - IsLocked = _attrIsLocked, - DataSourceReference = _attrDataSourceRef?.Trim() + IsLocked = draft.IsLocked, + DataSourceReference = draft.DataSourceRef?.Trim() }; var addResult = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user); - if (addResult.IsSuccess) - { - _showAttrForm = false; - _toast.ShowSuccess($"Attribute '{_attrName}' added."); - await LoadAsync(); - } - else - { - _attrFormError = addResult.Error; - } + if (!addResult.IsSuccess) return addResult.Error; + + _toast.ShowSuccess($"Attribute '{draft.Name}' added."); + return null; } private async Task DeleteAttribute(TemplateAttribute attr) @@ -1925,16 +1404,41 @@ } } - private void BeginAddAlarm() + // Alarm add/edit dialog: opened via IDialogService.ShowAsync (same contract as + // the attribute dialog — inline errors, close-on-success, page reloads after). + private Task OpenAddAlarmDialog() + => ShowAlarmDialogAsync( + editId: null, + new TemplateAlarmDialog.AlarmDraft(string.Empty, default, 500, null, false)); + + private Task OpenEditAlarmDialog(TemplateAlarm alarm) + => ShowAlarmDialogAsync( + alarm.Id, + new TemplateAlarmDialog.AlarmDraft( + alarm.Name, + alarm.TriggerType, + alarm.PriorityLevel, + alarm.TriggerConfiguration, + alarm.IsLocked)); + + private async Task ShowAlarmDialogAsync(int? editId, TemplateAlarmDialog.AlarmDraft initial) { - _showAlarmForm = true; - _editAlarmId = null; - _alarmFormError = null; - _alarmName = string.Empty; - _alarmPriority = 500; - _alarmTriggerType = default; - _alarmTriggerConfig = null; - _alarmIsLocked = false; + var editing = editId.HasValue; + var choices = BuildAlarmAttributeChoices(); + var saved = await Dialog.ShowAsync( + editing ? "Edit Alarm" : "Add Alarm", + ctx => @, + size: "modal-dialog-scrollable"); + + if (saved) await LoadAsync(); } /// @@ -1969,80 +1473,50 @@ return list; } - private void BeginEditAlarm(TemplateAlarm alarm) + /// + /// Persists an alarm draft submitted from TemplateAlarmDialog. Returns + /// null on success or the message the dialog renders inline. + /// + private async Task SaveAlarmDraftAsync(int? editId, TemplateAlarmDialog.AlarmDraft draft) { - _showAlarmForm = true; - _editAlarmId = alarm.Id; - _alarmFormError = null; - _alarmName = alarm.Name; - _alarmPriority = alarm.PriorityLevel; - _alarmTriggerType = alarm.TriggerType; - _alarmTriggerConfig = alarm.TriggerConfiguration; - _alarmIsLocked = alarm.IsLocked; - } - - private void CancelAlarmForm() - { - _showAlarmForm = false; - _editAlarmId = null; - _alarmFormError = null; - } - - private async Task SaveAlarm() - { - if (_selectedTemplate == null) return; - _alarmFormError = null; - if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; } + if (_selectedTemplate == null) return "Template is not loaded."; + if (string.IsNullOrWhiteSpace(draft.Name)) return "Name is required."; var user = await GetCurrentUserAsync(); - if (_editAlarmId is int id) + if (editId is int id) { var existing = _alarms.FirstOrDefault(a => a.Id == id); - if (existing == null) { _alarmFormError = "Alarm no longer exists."; return; } + if (existing == null) return "Alarm no longer exists."; var proposed = new TemplateAlarm(existing.Name) { TriggerType = existing.TriggerType, // fixed - PriorityLevel = _alarmPriority, - TriggerConfiguration = _alarmTriggerConfig?.Trim(), - IsLocked = _alarmIsLocked, + PriorityLevel = draft.Priority, + TriggerConfiguration = draft.TriggerConfig?.Trim(), + IsLocked = draft.IsLocked, Description = existing.Description, OnTriggerScriptId = existing.OnTriggerScriptId, }; var result = await TemplateService.UpdateAlarmAsync(id, proposed, user); - if (result.IsSuccess) - { - _showAlarmForm = false; - _editAlarmId = null; - _toast.ShowSuccess($"Alarm '{existing.Name}' updated."); - await LoadAsync(); - } - else - { - _alarmFormError = result.Error; - } - return; + if (!result.IsSuccess) return result.Error; + + _toast.ShowSuccess($"Alarm '{existing.Name}' updated."); + return null; } - var alarm = new TemplateAlarm(_alarmName.Trim()) + var alarm = new TemplateAlarm(draft.Name.Trim()) { - TriggerType = _alarmTriggerType, - PriorityLevel = _alarmPriority, - TriggerConfiguration = _alarmTriggerConfig?.Trim(), - IsLocked = _alarmIsLocked + TriggerType = draft.TriggerType, + PriorityLevel = draft.Priority, + TriggerConfiguration = draft.TriggerConfig?.Trim(), + IsLocked = draft.IsLocked }; var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user); - if (addResult.IsSuccess) - { - _showAlarmForm = false; - _toast.ShowSuccess($"Alarm '{_alarmName}' added."); - await LoadAsync(); - } - else - { - _alarmFormError = addResult.Error; - } + if (!addResult.IsSuccess) return addResult.Error; + + _toast.ShowSuccess($"Alarm '{draft.Name}' added."); + return null; } private async Task DeleteAlarm(TemplateAlarm alarm) @@ -2059,199 +1533,128 @@ else { _toast.ShowError(result.Error); } } - /// Applies the structured trigger editor's type + config atomically. - private void OnScriptTriggerChanged(ScriptTriggerValue v) + // Script add/edit dialog: opened via IDialogService.ShowAsync. The body owns + // the tabbed authoring surface (Monaco keeps its own @ref there so the editor + // survives tab switches) and the Test Run panel, which needs the live unsaved + // buffer. Persistence stays here and is reached through the OnSaveAsync + // delegate; errors render inline and keep the dialog open. + private Task OpenAddScriptDialog() + => ShowScriptDialogAsync( + editId: null, + new TemplateScriptDialog.ScriptDraft( + string.Empty, string.Empty, null, null, null, null, null, false, null)); + + private Task OpenEditScriptDialog(TemplateScript script) + => ShowScriptDialogAsync( + script.Id, + new TemplateScriptDialog.ScriptDraft( + script.Name, + script.Code, + script.TriggerType, + script.TriggerConfiguration, + script.MinTimeBetweenRuns, + script.ParameterDefinitions, + script.ReturnDefinition, + script.IsLocked, + script.ExecutionTimeoutSeconds)); + + private async Task ShowScriptDialogAsync(int? editId, TemplateScriptDialog.ScriptDraft initial) { - _scriptTriggerType = v.TriggerType; - _scriptTriggerConfig = v.Config; + var editing = editId.HasValue; + var choices = BuildAlarmAttributeChoices(); + var siblings = _scripts + .Select(s => ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse( + s.Name, s.ParameterDefinitions, s.ReturnDefinition)) + .ToArray(); + var selfAttributes = _attributes + .Select(a => new ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType))) + .ToArray(); + var children = _editorChildren; + var parent = ActiveEditorParent; + var instances = _deployedInstances; + + var saved = await Dialog.ShowAsync( + editing ? "Edit Script" : "Add Script", + ctx => @, + size: "modal-dialog-scrollable script-editor-modal"); + + if (saved) await LoadAsync(); } - /// - /// 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). - /// - private bool ScriptTriggerIsWhileTrue() - { - var kind = ScriptTriggerConfigCodec.ParseKind(_scriptTriggerType); - return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression - && ScriptTriggerConfigCodec.Parse(_scriptTriggerConfig, kind).Mode - == ScriptTriggerMode.WhileTrue; - } - - private void BeginAddScript() - { - _showScriptForm = true; - _editScriptId = null; - _scriptFormError = null; - _scriptName = string.Empty; - _scriptCode = string.Empty; - _scriptTriggerType = null; - _scriptTriggerConfig = null; - (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null); - _scriptParameters = null; - _scriptReturn = null; - _scriptIsLocked = false; - _scriptExecutionTimeoutSeconds = null; - _scriptModalTab = "trigger"; - ResetScriptTestRun(); - } - - private void BeginEditScript(TemplateScript script) - { - _showScriptForm = true; - _editScriptId = script.Id; - _scriptFormError = null; - _scriptName = script.Name; - _scriptCode = script.Code; - _scriptTriggerType = script.TriggerType; - _scriptTriggerConfig = script.TriggerConfiguration; - (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns); - _scriptParameters = script.ParameterDefinitions; - _scriptReturn = script.ReturnDefinition; - _scriptIsLocked = script.IsLocked; - // Load the per-script execution timeout into the authoring input (null = use site default). - _scriptExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds; - _scriptModalTab = "trigger"; - ResetScriptTestRun(); - } - - private void CancelScriptForm() - { - _showScriptForm = false; - _editScriptId = null; - _scriptFormError = null; - ResetScriptTestRun(); - } - - private void ResetScriptTestRun() - { - _showScriptTestRun = false; - _scriptRunning = false; - _scriptParamValues = new(); - _scriptBindInstance = string.Empty; - _scriptRunResult = null; - _scriptRunCts?.Cancel(); - _scriptRunCts = null; - } - - private void ToggleScriptTestRunPanel() => _showScriptTestRun = !_showScriptTestRun; - - private async Task RunScriptInSandboxAsync() - { - _scriptRunCts?.Cancel(); - _scriptRunCts = new CancellationTokenSource(); - _scriptRunning = true; - _scriptRunResult = null; - StateHasChanged(); - - try - { - var jsonParams = _scriptParamValues.ToDictionary( - kv => kv.Key, - kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value)); - var request = new ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxRunRequest( - _scriptCode, jsonParams, TimeoutSeconds: null, - BindInstanceUniqueName: string.IsNullOrEmpty(_scriptBindInstance) ? null : _scriptBindInstance); - _scriptRunResult = await AnalysisService.RunInSandboxAsync(request, _scriptRunCts.Token); - } - catch (OperationCanceledException) { /* superseded by next Run click */ } - catch (Exception ex) - { - _scriptRunResult = new ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxRunResult( - Success: false, - ReturnValueJson: null, - ReturnTypeName: null, - ConsoleOutput: "", - Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}", - ErrorKind: ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError, - DurationMs: 0, - Markers: null); - } - finally - { - _scriptRunning = false; - StateHasChanged(); - } - } - - private static string ScriptErrorKindLabel(ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind kind) => kind switch - { - ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error", - ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation", - ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error", - ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout", - _ => "Error" - }; - // Normalizes the execution-timeout input: a null or non-positive value means // "use the site default", so it is stored as null (matching the Site Runtime's // own ≤0-means-default handling and the entity's documented contract). private static int? NormalizeExecutionTimeout(int? seconds) => seconds is > 0 ? seconds : null; - private async Task SaveScript() + /// + /// Persists a script draft submitted from TemplateScriptDialog. Returns + /// null on success or the message the dialog renders inline. + /// + private async Task SaveScriptDraftAsync(int? editId, TemplateScriptDialog.ScriptDraft draft) { - if (_selectedTemplate == null) return; - _scriptFormError = null; - if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; } - if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; } + if (_selectedTemplate == null) return "Template is not loaded."; + if (string.IsNullOrWhiteSpace(draft.Name)) return "Name is required."; + if (string.IsNullOrWhiteSpace(draft.Code)) return "Code is required."; var user = await GetCurrentUserAsync(); - if (_editScriptId is int id) + if (editId is int id) { var existing = _scripts.FirstOrDefault(s => s.Id == id); - if (existing == null) { _scriptFormError = "Script no longer exists."; return; } - var proposed = new TemplateScript(existing.Name, _scriptCode) + if (existing == null) return "Script no longer exists."; + var proposed = new TemplateScript(existing.Name, draft.Code) { - TriggerType = _scriptTriggerType?.Trim(), - TriggerConfiguration = _scriptTriggerConfig?.Trim(), - ParameterDefinitions = _scriptParameters, - ReturnDefinition = _scriptReturn, - IsLocked = _scriptIsLocked, - MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit), - ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds), + TriggerType = draft.TriggerType?.Trim(), + TriggerConfiguration = draft.TriggerConfig?.Trim(), + ParameterDefinitions = draft.ParameterDefinitions, + ReturnDefinition = draft.ReturnDefinition, + IsLocked = draft.IsLocked, + MinTimeBetweenRuns = draft.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = NormalizeExecutionTimeout(draft.ExecutionTimeoutSeconds), IsInherited = existing.IsInherited, LockedInDerived = existing.LockedInDerived, }; var result = await TemplateService.UpdateScriptAsync(id, proposed, user); - if (result.IsSuccess) - { - _showScriptForm = false; - _editScriptId = null; - _toast.ShowSuccess($"Script '{existing.Name}' updated."); - await LoadAsync(); - } - else - { - _scriptFormError = result.Error; - } - return; + if (!result.IsSuccess) return result.Error; + + _toast.ShowSuccess($"Script '{existing.Name}' updated."); + return null; } - var script = new TemplateScript(_scriptName.Trim(), _scriptCode) + var script = new TemplateScript(draft.Name.Trim(), draft.Code) { - TriggerType = _scriptTriggerType?.Trim(), - TriggerConfiguration = _scriptTriggerConfig?.Trim(), - ParameterDefinitions = _scriptParameters, - ReturnDefinition = _scriptReturn, - IsLocked = _scriptIsLocked, - MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit), - ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds) + TriggerType = draft.TriggerType?.Trim(), + TriggerConfiguration = draft.TriggerConfig?.Trim(), + ParameterDefinitions = draft.ParameterDefinitions, + ReturnDefinition = draft.ReturnDefinition, + IsLocked = draft.IsLocked, + MinTimeBetweenRuns = draft.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = NormalizeExecutionTimeout(draft.ExecutionTimeoutSeconds) }; var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user); - if (addResult.IsSuccess) - { - _showScriptForm = false; - _toast.ShowSuccess($"Script '{_scriptName}' added."); - await LoadAsync(); - } - else - { - _scriptFormError = addResult.Error; - } + if (!addResult.IsSuccess) return addResult.Error; + + _toast.ShowSuccess($"Script '{draft.Name}' added."); + return null; } private async Task DeleteScript(TemplateScript script) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateNativeAlarmSourceDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateNativeAlarmSourceDialog.razor new file mode 100644 index 00000000..e11bec02 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateNativeAlarmSourceDialog.razor @@ -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). +*@ +
    +
    + + +
    +
    + + + @if (AlarmCapableConnections.Count == 0) + { +
    No OPC UA or MxGateway connections defined yet.
    + } +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + @if (_error != null) + { +
    @_error
    + } +
    + + + +@code { + /// Host-supplied context: Close(true) on a successful save, Cancel on dismiss. + [Parameter, EditorRequired] public DialogContext Context { get; set; } = default!; + + /// True when editing an existing source (the name becomes read-only). + [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; } + + /// Connections whose protocol can surface native alarms (OPC UA / MxGateway). + [Parameter] public IReadOnlyList AlarmCapableConnections { get; set; } + = Array.Empty(); + + /// + /// Persists the draft. Returns null on success (the dialog closes with + /// true) or the message to render inline, leaving the dialog open. + /// + [Parameter, EditorRequired] public Func> 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; + } + } + + /// The authored native alarm source as submitted; the page performs the add/update. + /// Source name (ignored on edit — the stored name is fixed). + /// Alarm-capable data connection to mirror from. + /// OPC UA SourceNode nodeId, or MxAccess object/area. + /// Optional condition filter; blank mirrors every condition. + /// Optional free-text description. + /// Whether instances may override the binding. + public sealed record NativeSourceDraft( + string Name, + string ConnectionName, + string SourceReference, + string? ConditionFilter, + string? Description, + bool IsLocked); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateScriptDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateScriptDialog.razor new file mode 100644 index 00000000..39cf8ce2 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateScriptDialog.razor @@ -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 + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +@* 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. *@ + +
    +
    + + @if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_triggerType)) + { +
    + +
    +
    + +
    +
    + +
    +
    + @if (TriggerIsWhileTrue()) + { +
    + This is the re-fire interval for the + WhileTrue trigger above. +
    + @if (DurationInput.Compose(_minTimeValue, _minTimeUnit) is null) + { +
    + The WhileTrue trigger has no interval set — the script + will fire only once. Set a value here to make it re-fire. +
    + } + } + else + { +
    + Optional throttle — skips trigger invocations that fire + sooner than this. +
    + } +
    + } +
    + +
    + + seconds +
    +
    + Per-script execution timeout. Leave blank (or 0) to use the + site's global default. +
    +
    +
    +
    + + +
    +
    + +
    +
    + +
    +
    + +@if (_error != null) +{ +
    @_error
    +} + +@if (_showTestRun) +{ +
    +
    + Test Run Real I/O +
    +
    + Heads up: + runs the script as typed (unsaved edits included) against the supplied + Parameters. + External, Database, and Notify calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent. + CallShared executes the named shared script (saved version) in the same sandbox. + Instance, Attributes, Children, Parent, and CallScript throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too). +
    +
    +
    + + @if (DeployedInstances.Count == 0) + { +
    + No running instances of this template. + Instance/Attributes/CallScript will throw. +
    + } + else + { + +
    + Routes Instance.GetAttribute/SetAttribute, + Attributes, Children, Parent, and + CallScript to the selected live instance. +
    + } +
    +
    + + +
    +
    + + @if (_runResult != null) + { + @_runResult.DurationMs ms + } +
    + + @if (_runResult != null) + { + @if (_runResult.Success) + { +
    + +
    @_runResult.ReturnValueJson
    +
    + } + else + { +
    + +
    @_runResult.Error
    + @if (_runResult.Markers is { Count: > 0 }) + { +
      + @foreach (var m in _runResult.Markers) + { +
    • Line @m.StartLineNumber, col @m.StartColumn: @m.Message @m.Code
    • + } +
    + } +
    + } + + @if (!string.IsNullOrEmpty(_runResult.ConsoleOutput)) + { +
    + +
    @_runResult.ConsoleOutput
    +
    + } + } +
    +
    +} + + + +@code { + /// Host-supplied context: Close(true) on a successful save, Cancel on dismiss. + [Parameter, EditorRequired] public DialogContext Context { get; set; } = default!; + + /// True when editing an existing script (the name becomes read-only). + [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; } + + /// Attribute choices offered by the structured trigger editor's picker. + [Parameter] public IReadOnlyList AvailableAttributes { get; set; } + = Array.Empty(); + + /// Sibling scripts on the same template, for CallScript completion. + [Parameter] public IReadOnlyList? SiblingScripts { get; set; } + + /// This template's own attributes, for Attributes.* completion. + [Parameter] public IReadOnlyList? SelfAttributes { get; set; } + + /// Composed children's contexts, for Children.* completion. + [Parameter] public IReadOnlyList? EditorChildren { get; set; } + + /// The slot-owner context, for Parent.* completion; null for a base template. + [Parameter] public CompositionContext? EditorParent { get; set; } + + /// Running instances of this template, offered as Test Run bind targets. + [Parameter] public IReadOnlyList DeployedInstances { get; set; } + = Array.Empty(); + + /// + /// Persists the draft. Returns null on success (the dialog closes with + /// true) or the message to render inline, leaving the dialog open. + /// + [Parameter, EditorRequired] public Func> 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 _markers = Array.Empty(); + + // Test Run state. + private bool _showTestRun; + private bool _running; + private Dictionary _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; + } + + /// Cancels an in-flight Test Run when the dialog is torn down. + public void Dispose() + { + _runCts?.Cancel(); + _runCts?.Dispose(); + _runCts = null; + } + + /// Applies the structured trigger editor's type + config atomically. + private void OnTriggerChanged(ScriptTriggerValue v) + { + _triggerType = v.TriggerType; + _triggerConfig = v.Config; + } + + /// + /// 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). + /// + 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; + } + } + + /// The authored script as submitted; the page performs the add/update. + /// Script name (ignored on edit — the stored name is fixed). + /// Script body as typed. + /// Structured trigger type token, or null. + /// Structured trigger configuration payload, or null. + /// Composed throttle / re-fire interval, or null. + /// Parameter schema JSON, or null. + /// Return-type schema JSON, or null. + /// Whether instances may override the script. + /// Raw timeout input; the page normalizes ≤0 to null. + public sealed record ScriptDraft( + string Name, + string Code, + string? TriggerType, + string? TriggerConfig, + TimeSpan? MinTimeBetweenRuns, + string? ParameterDefinitions, + string? ReturnDefinition, + bool IsLocked, + int? ExecutionTimeoutSeconds); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs index 654c7c5f..b72a51a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/AttributeListEditorTests.cs @@ -14,18 +14,27 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design; ///
    public class AttributeListEditorTests : BunitContext { - private static string TemplateEditMarkup + private static string DesignComponentsRoot { get { var dir = AppContext.BaseDirectory; for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName; - return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", - "Components", "Pages", "Design", "TemplateEdit.razor")); + return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", + "Components", "Pages", "Design"); } } + private static string TemplateEditMarkup + => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor")); + + // M10 residual: the attribute authoring form moved out of the page into the + // dialog body component hosted by IDialogService.ShowAsync. Submit-side + // encoding stays on the page (it owns TemplateService). + private static string AttributeDialogMarkup + => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateAttributeDialog.razor")); + [Fact] public void Editor_RendersElementTypeSelect_WithSixValidScalars() { @@ -117,16 +126,18 @@ public class AttributeListEditorTests : BunitContext [Fact] public void TemplateEdit_RevealsListEditor_AndSendsElementType() { - var markup = TemplateEditMarkup; + var dialogMarkup = AttributeDialogMarkup; // Conditional reveal on DataType.List. - Assert.Contains("_attrDataType == DataType.List", markup); - Assert.Contains(" public class TemplateNativeAlarmSourceEditorTests { - private static string TemplateEditMarkup + private static string DesignComponentsRoot { get { var dir = AppContext.BaseDirectory; for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName; - return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", - "Components", "Pages", "Design", "TemplateEdit.razor")); + return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", + "Components", "Pages", "Design"); } } + private static string TemplateEditMarkup + => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor")); + + // M10 residual: the authoring form moved out of the page into the dialog body + // component hosted by IDialogService.ShowAsync. The field-level assertions + // below follow it there; the tab, the connection filtering, and the CRUD + // wiring all still live on the page. + private static string NativeSourceDialogMarkup + => File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateNativeAlarmSourceDialog.razor")); + [Fact] public void TemplateEditor_HasNativeAlarmsTab() { @@ -38,19 +48,39 @@ public class TemplateNativeAlarmSourceEditorTests [Fact] public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields() { - var markup = TemplateEditMarkup; + var pageMarkup = TemplateEditMarkup; // Connection dropdown filtered to alarm-capable protocols via the // single-source-of-truth Commons helper. The OpcUa/MxGateway literal set // now lives in AlarmCapableProtocols (Commons) — pinned by // AlarmCapableProtocolsTests — so this page only needs to delegate to it. - Assert.Contains("_alarmCapableConnections", markup); - Assert.Contains("AlarmCapableProtocols.IsAlarmCapable", markup); - // The authoring form fields. - Assert.Contains("@bind=\"_nasName\"", markup); - Assert.Contains("@bind=\"_nasConnection\"", markup); - Assert.Contains("@bind=\"_nasSourceRef\"", markup); - Assert.Contains("@bind=\"_nasFilter\"", markup); - Assert.Contains("@bind=\"_nasIsLocked\"", markup); + Assert.Contains("_alarmCapableConnections", pageMarkup); + Assert.Contains("AlarmCapableProtocols.IsAlarmCapable", pageMarkup); + // …and hands the filtered list to the dialog body. + Assert.Contains("AlarmCapableConnections=", pageMarkup); + + // The authoring form fields now live in the dialog body component. + var dialogMarkup = NativeSourceDialogMarkup; + Assert.Contains("@bind=\"_nasName\"", dialogMarkup); + Assert.Contains("@bind=\"_nasConnection\"", dialogMarkup); + Assert.Contains("@bind=\"_nasSourceRef\"", dialogMarkup); + Assert.Contains("@bind=\"_nasFilter\"", dialogMarkup); + Assert.Contains("@bind=\"_nasIsLocked\"", dialogMarkup); + } + + [Fact] + public void NativeAlarmsForm_IsHostedByTheDialogService() + { + // M10 residual: the page-embedded modal is gone — the form opens through + // IDialogService.ShowAsync so the host owns the backdrop, focus trap, + // Escape, and focus restoration. + var pageMarkup = TemplateEditMarkup; + Assert.Contains(" Context", dialogMarkup); } [Fact] @@ -63,9 +93,9 @@ public class TemplateNativeAlarmSourceEditorTests Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup); Assert.Contains("SaveChangesAsync", markup); // Add/edit/delete handlers are wired to the UI. - Assert.Contains("BeginAddNativeSource", markup); - Assert.Contains("BeginEditNativeSource", markup); - Assert.Contains("SaveNativeSource", markup); + Assert.Contains("OpenAddNativeSourceDialog", markup); + Assert.Contains("OpenEditNativeSourceDialog", markup); + Assert.Contains("SaveNativeSourceDraftAsync", markup); Assert.Contains("DeleteNativeSource", markup); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs index c8b9de09..8d51263f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TestRunWarningTests.cs @@ -29,9 +29,12 @@ public class TestRunWarningTests private static string Read(string fileName) => File.ReadAllText(Path.Combine(SrcRoot, fileName)); + // M10 residual: the template Test Run panel moved out of TemplateEdit.razor + // into TemplateScriptDialog.razor when the page-embedded script modal was + // migrated to the IDialogService host. The warning travelled with it. [Theory] [InlineData("SharedScriptForm.razor")] - [InlineData("TemplateEdit.razor")] + [InlineData("TemplateScriptDialog.razor")] public void TestRunPanel_WithRealIoSurface_ShowsRealIoBadgeAndWarning(string razorFile) { var markup = Read(razorFile);