From c98625fd9fbce78022ff6d183c6ad772e4753e46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 16:44:14 -0400 Subject: [PATCH] feat(adminui): create-new-script from the inline virtual-tag panel --- .../Shared/Uns/VirtualTagModal.razor | 70 ++++++++++++++- .../Uns/IUnsTreeService.cs | 13 +++ .../Uns/UnsTreeService.cs | 24 ++++++ .../Uns/UnsTreeServiceCreateScriptTests.cs | 86 +++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceCreateScriptTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor index db93f2c1..395e2c61 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -50,7 +50,7 @@ - @foreach (var (id, display) in Scripts) + @foreach (var (id, display) in _scripts) { } @@ -59,6 +59,25 @@ + @* No script bound yet → offer a one-click "New script" action that creates a + fresh (blank) Script row, binds this virtual tag to it, and expands the inline + editor below so the operator can author the body without leaving the modal. *@ + @if (string.IsNullOrEmpty(_form.ScriptId)) + { +
+ + Creates a blank script bound to this virtual tag. + @if (!string.IsNullOrWhiteSpace(_scriptCreateError)) + { +
@_scriptCreateError
+ } +
+ } + @* Inline script-source editor. Shown only when a script is bound. This panel saves the SHARED Script row on its OWN concurrency-guarded button — it is deliberately separate from the virtual-tag Create/Save below and never touches _form or closes @@ -178,6 +197,10 @@ private bool _busy; private string? _error; + // Working copy of the selectable scripts: the host-supplied list plus any script created inline via + // "New script", so the freshly-created (and now-bound) script is a real option in the dropdown. + private List<(string Id, string Display)> _scripts = new(); + // Tracks which open this modal last loaded for, so unrelated Blazor Server re-renders don't // rebuild _form / reload the script source and clobber in-progress edits. Null while closed. private string? _loadedKey; @@ -193,6 +216,10 @@ private bool _scriptSaved; private string? _scriptError; + // "New script" action state (shown only when no script is bound yet). + private bool _scriptCreating; + private string? _scriptCreateError; + protected override async Task OnParametersSetAsync() { if (!Visible) @@ -209,6 +236,10 @@ if (key == _loadedKey) return; // same open, re-render → preserve in-progress form + script edits _loadedKey = key; + // Snapshot the host-supplied script options; "New script" appends to this working copy so the + // newly-created script is selectable without mutating the parent's parameter. + _scripts = Scripts.Select(s => (s.Id, s.Display)).ToList(); + // Rebuild the working form whenever the host (re)opens the modal for a fresh target. if (IsNew) { @@ -276,6 +307,43 @@ /// Refreshes the inline script panel when the operator picks a different script. private Task OnScriptChangedAsync() => LoadScriptSourceAsync(); + /// + /// Creates a brand-new (blank) script, binds this virtual tag to it, and expands the inline editor + /// so the operator can author the body without leaving the modal. The new script name seeds from the + /// virtual tag's name when set. This does NOT save the virtual tag — the binding lives in _form until + /// the operator clicks Create/Save below; only the new Script row is persisted here. On failure the + /// inline error is surfaced and the binding is left untouched. + /// + private async Task CreateNewScriptAsync() + { + _scriptCreating = true; + _scriptCreateError = null; + try + { + var seedName = string.IsNullOrWhiteSpace(_form.Name) ? "New script" : $"{_form.Name} script"; + var result = await Svc.CreateScriptAsync(seedName); + if (!result.Ok || string.IsNullOrEmpty(result.CreatedId)) + { + _scriptCreateError = result.Error ?? "Could not create the script."; + return; + } + + // Bind the new script and load its (blank) source so the inline editor renders + expands. + _form.ScriptId = result.CreatedId; + await LoadScriptSourceAsync(); + // Make the freshly-created script a real option in the dropdown so the select stays coherent. + if (!_scripts.Any(s => s.Id == result.CreatedId)) + { + _scripts.Add((result.CreatedId, $"{seedName} (CSharp)")); + } + _scriptExpanded = true; + } + finally + { + _scriptCreating = false; + } + } + /// /// Toggles the inline script-source panel. Clears the stale "Script saved." banner when /// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index f043645d..4c9fcb84 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -418,6 +418,19 @@ public interface IUnsTreeService /// The scripts projected to (ScriptId, Display) pairs. Task> LoadScriptsAsync(CancellationToken ct = default); + /// + /// Creates a new, empty C# script row so the inline virtual-tag panel can bind a brand-new script + /// without leaving the modal. The ScriptId is system-generated (SC- + the first 12 hex + /// chars of a fresh , mirroring the Script-edit page's convention); the source is + /// blank, the SourceHash is the SHA-256 of that blank source (so it stays consistent with the + /// inline save), and the language is CSharp. A whitespace-only name falls back to a default. + /// The new id is returned via so the caller can bind it. + /// + /// The operator-friendly script name; whitespace falls back to a default. + /// A token to cancel the operation. + /// Success carrying the generated SC-… id in . + Task CreateScriptAsync(string name, CancellationToken ct = default); + /// /// Creates a new equipment-bound virtual tag (plan decision #2 — virtual tags are always scoped /// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index de8be809..d62c77d1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1011,6 +1011,30 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToList(); } + /// + public async Task CreateScriptAsync(string name, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // System-generated id, mirroring the Script-edit page's "SC-{12 hex}" convention. + var scriptId = $"SC-{Guid.NewGuid().ToString("N")[..12]}"; + var resolvedName = string.IsNullOrWhiteSpace(name) ? "New script" : name.Trim(); + + // Blank source; SourceHash is the SHA-256 of that blank body so it stays consistent with the + // inline save (UpdateScriptSourceAsync recomputes it the same way). + db.Scripts.Add(new Script + { + ScriptId = scriptId, + Name = resolvedName, + Language = "CSharp", + SourceCode = "", + SourceHash = HashSource(""), + }); + + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, scriptId); + } + /// /// When the bound script uses the reserved {{equip}} token, the owning equipment must have /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceCreateScriptTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceCreateScriptTests.cs new file mode 100644 index 00000000..30d603e3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceCreateScriptTests.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies — the create-new-script path the inline +/// virtual-tag panel calls when no script is bound yet. A new Script row is inserted with a +/// system-generated SC-… id (mirroring the Script-edit page convention), a blank source, a +/// hash matching that blank source, and the CSharp language; the new id is returned via +/// so the panel can bind the virtual tag to it. Two calls +/// produce two distinct rows with distinct ids. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceCreateScriptTests +{ + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-createscript-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + private static string HashOf(string source) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))); + + /// + /// A create inserts exactly one Script row carrying a non-empty generated id, the expected blank + /// source + matching hash, the CSharp language, and a name; the generated id is echoed back. + /// + [Fact] + public async Task CreateScript_inserts_one_row_with_generated_id_and_blank_source() + { + var (service, dbName) = Fresh(); + + var result = await service.CreateScriptAsync("my new script"); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + result.CreatedId.ShouldNotBeNullOrWhiteSpace(); + result.CreatedId!.ShouldStartWith("SC-"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Scripts.Count().ShouldBe(1); + var script = db.Scripts.Single(); + script.ScriptId.ShouldBe(result.CreatedId); + script.Name.ShouldBe("my new script"); + script.SourceCode.ShouldBe(""); + script.SourceHash.ShouldBe(HashOf("")); + script.Language.ShouldBe("CSharp"); + } + + /// A whitespace/empty name falls back to a sensible non-empty default. + [Fact] + public async Task CreateScript_blank_name_uses_a_default_name() + { + var (service, dbName) = Fresh(); + + var result = await service.CreateScriptAsync(" "); + + result.Ok.ShouldBeTrue(); + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Scripts.Single().Name.ShouldNotBeNullOrWhiteSpace(); + } + + /// Two creates insert two rows with distinct, non-empty ids. + [Fact] + public async Task CreateScript_twice_produces_distinct_ids() + { + var (service, dbName) = Fresh(); + + var first = await service.CreateScriptAsync("first"); + var second = await service.CreateScriptAsync("second"); + + first.Ok.ShouldBeTrue(); + second.Ok.ShouldBeTrue(); + first.CreatedId.ShouldNotBe(second.CreatedId); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Scripts.Count().ShouldBe(2); + db.Scripts.Select(s => s.ScriptId).Distinct().Count().ShouldBe(2); + } +}