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);