From fd9fa75d0edc4d4b25ffbb22914e42c6d4a0cb70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 09:22:12 -0400 Subject: [PATCH] feat(uns): TagConfig JSON helper + editor map + TagModal dispatch scaffold (F-uns-1 T2) --- .../Components/Shared/Uns/TagModal.razor | 35 ++++- .../Uns/TagEditors/TagConfigEditorMap.cs | 20 +++ .../Uns/TagEditors/TagConfigJson.cs | 39 +++++ .../Uns/TagConfigJsonTests.cs | 134 ++++++++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor index 78a66422..c88e68c6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor @@ -7,6 +7,7 @@ @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.Configuration.Enums @inject IUnsTreeService Svc @@ -81,11 +82,25 @@
- - -
Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.
+ + @{ + var editorType = TagConfigEditorMap.Resolve(SelectedDriverType); + } + @if (string.IsNullOrEmpty(_form.DriverInstanceId)) + { +
Pick a driver above to configure this tag.
+ } + else if (editorType is not null) + { + + } + else + { + +
Schemaless per driver type. Validated server-side at deploy.
+ }
@@ -137,6 +152,16 @@ private bool _busy; private string? _error; + // The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen. + private string? SelectedDriverType => + Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType; + + private IDictionary BuildEditorParameters() => new Dictionary + { + ["ConfigJson"] = _form.TagConfig, + ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), + }; + protected override void OnParametersSet() { // Rebuild the working form whenever the host (re)opens the modal for a fresh target. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs new file mode 100644 index 00000000..e754f649 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Maps a driver's DriverType string to its typed tag-config editor component (mirrors +/// DriverEditRouter._componentMap). Drivers absent from the map fall back to the generic +/// raw-JSON textarea in the TagModal. +/// +public static class TagConfigEditorMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Editors registered by later tasks, e.g.: + // ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor), + }; + + /// Returns the editor component type for a driver type, or null if none is registered. + public static Type? Resolve(string? driverType) + => driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs new file mode 100644 index 00000000..33e15e73 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable +/// (preserving every key, so fields the editor doesn't expose survive a +/// load→save), read typed scalars, and serialise back. +/// +public static class TagConfigJson +{ + /// Parses into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input. + public static JsonObject ParseOrNew(string? json) + { + if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); } + try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); } + catch (JsonException) { return new JsonObject(); } + } + + /// Serialises the object to compact JSON (JsonNode.ToJsonString() defaults to non-indented). + public static string Serialize(JsonObject obj) => obj.ToJsonString(); + + /// Reads a string value, or null if absent/null/non-string. + public static string? GetString(JsonObject o, string name) + => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var s) ? s : null; + + /// Reads an int value, or if absent/null/non-numeric (incl. object/array nodes). + public static int GetInt(JsonObject o, string name, int fallback = 0) + => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var i) ? i : fallback; + + /// Reads an enum by its serialised name, or if absent/unparseable. + public static TEnum GetEnum(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum + => GetString(o, name) is { } s && Enum.TryParse(s, ignoreCase: true, out var v) ? v : fallback; + + /// Sets a string/number/enum-name value (enums via ToString()). Null removes the key. + public static void Set(JsonObject o, string name, object? value) + => o[name] = value is null ? null : JsonValue.Create(value is Enum e ? e.ToString() : value); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs new file mode 100644 index 00000000..87e2f0d9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs @@ -0,0 +1,134 @@ +using System.Text.Json.Nodes; +using Shouldly; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class TagConfigJsonTests +{ + private enum Flavor { None, Vanilla, Chocolate } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{not json")] + [InlineData("[1,2]")] + public void ParseOrNew_returns_empty_object_for_unusable_input(string? json) + { + var obj = TagConfigJson.ParseOrNew(json); + + obj.ShouldNotBeNull(); + obj.Count.ShouldBe(0); + } + + [Fact] + public void ParseOrNew_parses_an_object() + { + var obj = TagConfigJson.ParseOrNew("""{"foo":"bar"}"""); + + obj.Count.ShouldBe(1); + TagConfigJson.GetString(obj, "foo").ShouldBe("bar"); + } + + [Fact] + public void RoundTrip_preserves_unknown_keys() + { + var obj = TagConfigJson.ParseOrNew("""{"foo":"bar","region":"X"}"""); + + TagConfigJson.Set(obj, "region", "Y"); + var json = TagConfigJson.Serialize(obj); + + json.ShouldContain("\"foo\":\"bar\""); + json.ShouldContain("\"region\":\"Y\""); + } + + [Fact] + public void GetString_returns_null_when_absent() + { + var obj = new JsonObject(); + + TagConfigJson.GetString(obj, "missing").ShouldBeNull(); + } + + [Fact] + public void GetInt_reads_an_int() + { + var obj = TagConfigJson.ParseOrNew("""{"register":40001}"""); + + TagConfigJson.GetInt(obj, "register").ShouldBe(40001); + } + + [Fact] + public void GetInt_falls_back_when_absent() + { + var obj = new JsonObject(); + + TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7); + } + + [Fact] + public void GetInt_falls_back_when_value_is_a_non_scalar_node() + { + // A nested object/array under the key must not throw — GetInt returns the fallback. + var obj = TagConfigJson.ParseOrNew("""{"register":{},"other":[1,2]}"""); + + TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7); + TagConfigJson.GetInt(obj, "other", fallback: 9).ShouldBe(9); + } + + [Fact] + public void GetString_returns_null_when_value_is_a_non_string_node() + { + var obj = TagConfigJson.ParseOrNew("""{"register":40001,"nested":{}}"""); + + TagConfigJson.GetString(obj, "register").ShouldBeNull(); + TagConfigJson.GetString(obj, "nested").ShouldBeNull(); + } + + [Fact] + public void GetEnum_parses_by_name_case_insensitive() + { + var obj = TagConfigJson.ParseOrNew("""{"flavor":"chocolate"}"""); + + TagConfigJson.GetEnum(obj, "flavor", Flavor.None).ShouldBe(Flavor.Chocolate); + } + + [Fact] + public void GetEnum_falls_back_when_absent() + { + var obj = new JsonObject(); + + TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla); + } + + [Fact] + public void GetEnum_falls_back_on_garbage() + { + var obj = TagConfigJson.ParseOrNew("""{"flavor":"strawberry"}"""); + + TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla); + } + + [Fact] + public void Set_writes_an_enum_as_its_name_string() + { + var obj = new JsonObject(); + + TagConfigJson.Set(obj, "flavor", Flavor.Chocolate); + var json = TagConfigJson.Serialize(obj); + + json.ShouldContain($"\"flavor\":\"{Flavor.Chocolate}\""); + } + + [Fact] + public void Set_with_null_removes_the_key() + { + var obj = TagConfigJson.ParseOrNew("""{"region":"X"}"""); + + TagConfigJson.Set(obj, "region", null); + + TagConfigJson.GetString(obj, "region").ShouldBeNull(); + } +}