From ada552fbec67e8c27c6b44046fa9a36cee4cc724 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:08:05 -0400 Subject: [PATCH] feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Shared/Raw/RawManualTagEntryModal.razor | 289 ++++++++++++++++++ .../Components/Shared/Raw/RawTagModal.razor | 257 ++++++++++++++++ .../CalculationTagConfigEditor.razor | 279 +++++++++++++++++ .../TagEditors/CalculationTagConfigModel.cs | 76 +++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../Uns/CalculationTagConfigModelTests.cs | 115 +++++++ 7 files changed, 1018 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CalculationTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CalculationTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor new file mode 100644 index 00000000..3f4b5c9d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -0,0 +1,289 @@ +@* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a + TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a + per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row — the grid stays + compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each + row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so + nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@ +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IRawTreeService Svc + +@if (Visible) +{ + + +} + +@code { + private static readonly string[] DataTypes = + ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", + "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"]; + + /// Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close. + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (self-close). Completes the @bind-Visible contract. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device the new tags are created under. + [Parameter] public string DeviceId { get; set; } = ""; + + /// The containing tag group, or null to create the tags directly under the device. + [Parameter] public string? TagGroupId { get; set; } + + /// The owning device's driver type, used for typed TagConfig-editor dispatch. + [Parameter] public string DriverType { get; set; } = ""; + + /// Raised after at least one tag is committed so the host can refresh the tree. + [Parameter] public EventCallback OnSaved { get; set; } + + private readonly List _rows = new(); + private bool _busy; + private string? _summary; + private bool _summaryOk; + private bool _open; // guards re-seeding rows on unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) + { + _open = false; + return; + } + if (_open) { return; } // same open, re-render → preserve in-progress rows + _open = true; + _rows.Clear(); + _rows.Add(new RowModel()); + _summary = null; + _summaryOk = false; + } + + private IDictionary BuildEditorParameters(RowModel row) => new Dictionary + { + ["ConfigJson"] = row.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => row.TagConfig = v), + ["DriverType"] = DriverType, + ["GetDriverConfigJson"] = (Func)(() => "{}"), + }; + + private void AddRow() => _rows.Add(new RowModel()); + + private void RemoveRow(RowModel row) + { + if (_rows.Count > 1) { _rows.Remove(row); } + } + + private static TagAccessLevel ParseAccess(object? v) + => Enum.TryParse(v?.ToString(), out var a) ? a : TagAccessLevel.Read; + + private async Task CommitAsync() + { + _busy = true; + _summary = null; + try + { + // --- Client-side validation first: name segment + per-driver config + intra-grid dup names. --- + foreach (var row in _rows) { row.Error = null; } + + var seen = new HashSet(RawPaths.Comparer); + var anyClientError = false; + foreach (var row in _rows) + { + var nameError = RawPaths.ValidateSegment(row.Name); + if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; } + if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; } + var configError = TagConfigValidator.Validate(DriverType, row.TagConfig); + if (configError is not null) { row.Error = configError; anyClientError = true; } + } + if (anyClientError) + { + _summaryOk = false; + _summary = "Fix the highlighted rows and try again."; + return; + } + + // --- Commit each row; keep failures (with their error), drop committed rows. --- + var created = 0; + var failed = 0; + foreach (var row in _rows) + { + var input = new RawTagInput( + row.Name, + row.DataType, + row.AccessLevel, + row.WriteIdempotent, + string.IsNullOrWhiteSpace(row.PollGroupId) ? null : row.PollGroupId, + string.IsNullOrWhiteSpace(row.TagConfig) ? "{}" : row.TagConfig); + + var result = await Svc.CreateTagAsync(DeviceId, TagGroupId, input); + if (result.Ok) + { + row.Committed = true; + created++; + } + else + { + row.Error = result.Error; + failed++; + } + } + + _rows.RemoveAll(r => r.Committed); + + if (failed == 0) + { + if (created > 0) { await OnSaved.InvokeAsync(); } + await CloseAsync(); + } + else + { + // Some succeeded, some failed: refresh the tree for the good ones, keep the modal open on + // the failed rows so the operator can correct them without re-entering the whole batch. + if (created > 0) { await OnSaved.InvokeAsync(); } + _summaryOk = false; + _summary = $"{created} tag(s) created; {failed} failed. Fix the highlighted rows and retry."; + } + } + finally + { + _busy = false; + } + } + + private async Task CloseAsync() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } + + private sealed class RowModel + { + public string Key { get; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = ""; + public string DataType { get; set; } = "Float"; + public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read; + public bool WriteIdempotent { get; set; } + public string? PollGroupId { get; set; } + public string TagConfig { get; set; } = "{}"; + public bool Expanded { get; set; } + public bool Committed { get; set; } + public string? Error { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor new file mode 100644 index 00000000..4f6ed3f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor @@ -0,0 +1,257 @@ +@* Edit modal for a single raw Tag in the v3 /raw tree. Unlike the equipment TagModal there is NO + driver selector — the driver is fixed by the device the tag lives under, so the owning DriverType is + passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for + unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it + loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful + save raises OnSaved so the coordinator can refresh the tree. *@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IRawTreeService Svc + +@if (Visible) +{ + + +} + +@code { + private static readonly string[] DataTypes = + ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", + "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"]; + + /// Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close. + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (self-close). Completes the @bind-Visible contract. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The raw tag to edit. The modal loads it via LoadTagForEditAsync on open. + [Parameter] public string? TagId { get; set; } + + /// The owning device's driver type, used for typed TagConfig-editor dispatch. + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful save so the host can refresh the tree. + [Parameter] public EventCallback OnSaved { get; set; } + + private FormModel _form = new(); + private byte[] _rowVersion = Array.Empty(); + private bool _busy; + private string? _error; + private string? _nameError; + private string? _loadError; + + // Tracks which (open, tag) this modal last loaded for, so unrelated Blazor Server re-renders don't + // reload + clobber in-progress edits. Null while closed. + private string? _loadedKey; + + protected override async Task OnParametersSetAsync() + { + if (!Visible) + { + _loadedKey = null; // closed → next open reloads fresh + return; + } + + var key = TagId; + if (key == _loadedKey) { return; } // same open, re-render → preserve in-progress edits + _loadedKey = key; + + _error = null; + _nameError = null; + _loadError = null; + + if (string.IsNullOrEmpty(TagId)) + { + _loadError = "No tag selected."; + return; + } + + var dto = await Svc.LoadTagForEditAsync(TagId); + // RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null. + if (dto is not { } d || string.IsNullOrEmpty(d.TagId)) + { + _loadError = "This tag no longer exists."; + return; + } + + _form = new FormModel + { + Name = d.Name, + DataType = string.IsNullOrEmpty(d.DataType) ? "Float" : d.DataType, + AccessLevel = d.AccessLevel, + WriteIdempotent = d.WriteIdempotent, + PollGroupId = d.PollGroupId, + TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig, + }; + _rowVersion = d.RowVersion; + } + + private IDictionary BuildEditorParameters() => new Dictionary + { + ["ConfigJson"] = _form.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), + ["DriverType"] = DriverType ?? "", + ["GetDriverConfigJson"] = (Func)(() => "{}"), + }; + + private async Task SaveAsync() + { + _busy = true; + _error = null; + _nameError = null; + try + { + // Client-side name validation (RawPath segment) so a bad name is caught before the round-trip. + var nameError = RawPaths.ValidateSegment(_form.Name); + if (nameError is not null) + { + _nameError = nameError; + return; + } + + // Client-side per-driver config validation (the typed editor's Validate()). + var configError = TagConfigValidator.Validate(DriverType, _form.TagConfig); + if (configError is not null) + { + _error = configError; + return; + } + + var input = new RawTagInput( + _form.Name, + _form.DataType, + _form.AccessLevel, + _form.WriteIdempotent, + string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId, + string.IsNullOrWhiteSpace(_form.TagConfig) ? "{}" : _form.TagConfig); + + var result = await Svc.UpdateTagAsync(TagId!, input, _rowVersion); + if (result.Ok) + { + await OnSaved.InvokeAsync(); + await CloseAsync(); + } + else + { + _error = result.Error; + } + } + finally + { + _busy = false; + } + } + + private async Task CloseAsync() + { + Visible = false; + _loadedKey = null; + await VisibleChanged.InvokeAsync(false); + } + + private sealed class FormModel + { + [Required] public string Name { get; set; } = ""; + public string DataType { get; set; } = "Float"; + public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read; + public bool WriteIdempotent { get; set; } + public string? PollGroupId { get; set; } + [Required] public string TagConfig { get; set; } = "{}"; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor new file mode 100644 index 00000000..9a49751a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/CalculationTagConfigEditor.razor @@ -0,0 +1,279 @@ +@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding — + scriptId + change/timer triggers — reusing the VirtualTagModal's script dropdown, "New + script" action, and inline Monaco source panel (all backed by the same IUnsTreeService + script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap, + so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter + shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch + uniformity but unused (a calc tag has no address to browse). *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@inject IUnsTreeService Svc + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
Blank = change-trigger only. Min 50 ms.
+
+
+ +@* No script bound yet → offer a one-click "New script" that creates a blank Script row, binds + it, and expands the inline editor so the operator can author the body without leaving. *@ +@if (string.IsNullOrEmpty(_m.ScriptId)) +{ +
+ + Creates a blank C# script bound to this calc tag. + @if (!string.IsNullOrWhiteSpace(_scriptCreateError)) + { +
@_scriptCreateError
+ } +
+} + +@* Inline script-source editor — shown only when a script is bound. Saves the SHARED Script row on + its own concurrency-guarded button; independent of the enclosing tag save. *@ +@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded) +{ +
+
+ Script source + +
+ @if (_scriptExpanded) + { +
+
+ Editing shared script "@_scriptName" — used by + @_scriptUsageCount virtual tag(s). Changes affect every tag using it. +
+ + @if (!string.IsNullOrWhiteSpace(_scriptError)) + { +
@_scriptError
+ } + else if (_scriptSaved) + { +
Script saved.
+ } +
+ +
+
+ } +
+} + +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — accepted for dispatch uniformity; unused here. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig — accepted for dispatch uniformity; + /// unused (a calc tag has no address to browse). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private CalculationTagConfigModel _m = new(); + private string? _lastConfigJson; + // Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids. + private readonly string _uid = Guid.NewGuid().ToString("N")[..8]; + + // Script dropdown options — loaded once, plus any inline-created script appended. + private List<(string Id, string Display)> _scripts = new(); + + // Inline script-source editor state (independent of the tag save). + private string _scriptSource = ""; + private bool _scriptLoaded; + private byte[] _scriptRowVersion = Array.Empty(); + private string? _scriptName; + private int _scriptUsageCount; + private bool _scriptExpanded; + private bool _scriptSaving; + private bool _scriptSaved; + private string? _scriptError; + + // "New script" action state. + private bool _scriptCreating; + private string? _scriptCreateError; + + protected override async Task OnInitializedAsync() + { + var scripts = await Svc.LoadScriptsAsync(); + _scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList(); + } + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes) can't reset the operator's in-progress edits. + protected override async Task OnParametersSetAsync() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = CalculationTagConfigModel.FromJson(ConfigJson); + await LoadScriptSourceAsync(); + } + + private async Task Update(Action apply) + { + apply(); + await WriteBackAsync(); + } + + // Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse. + private async Task WriteBackAsync() + { + var json = _m.ToJson(); + _lastConfigJson = json; + await ConfigJsonChanged.InvokeAsync(json); + } + + private static int? ParseTimer(object? v) + => int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null; + + /// Refreshes the inline script panel + writes the new binding back when the operator picks + /// a different script. + private async Task OnScriptChangedAsync(ChangeEventArgs e) + { + _m.ScriptId = e.Value?.ToString() ?? ""; + await WriteBackAsync(); + await LoadScriptSourceAsync(); + } + + /// Loads the bound script's source for the inline panel; clears it when no script is bound. + private async Task LoadScriptSourceAsync() + { + _scriptSaved = false; + _scriptError = null; + + if (string.IsNullOrEmpty(_m.ScriptId)) + { + _scriptSource = ""; + _scriptLoaded = false; + _scriptRowVersion = Array.Empty(); + _scriptName = null; + _scriptUsageCount = 0; + return; + } + + var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId); + if (loaded is null) + { + _scriptSource = ""; + _scriptLoaded = false; + _scriptRowVersion = Array.Empty(); + _scriptName = null; + _scriptUsageCount = 0; + return; + } + + _scriptSource = loaded.Value.SourceCode; + _scriptLoaded = true; + _scriptRowVersion = loaded.Value.RowVersion; + _scriptName = loaded.Value.Name; + _scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId); + } + + /// Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists + /// only the new Script row — the tag binding lives in the model until the host's save. + private async Task CreateNewScriptAsync() + { + _scriptCreating = true; + _scriptCreateError = null; + try + { + const string seedName = "Calc script"; + var result = await Svc.CreateScriptAsync(seedName); + if (!result.Ok || string.IsNullOrEmpty(result.CreatedId)) + { + _scriptCreateError = result.Error ?? "Could not create the script."; + return; + } + + // Add the option BEFORE binding so the