From 15f3797f1e192ae7b64e0b39da5f0f76418e2a7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 09:26:25 -0400 Subject: [PATCH] feat(adminui): editable AbLegacy device + tag lists via CollectionEditor --- .../Clusters/Drivers/AbLegacyDriverPage.razor | 192 ++++++++++++++---- ...bLegacyDriverPageFormSerializationTests.cs | 105 ++++++++++ 2 files changed, 263 insertions(+), 34 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor index de836c4f..3486b554 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor @@ -112,30 +112,65 @@ else - @* Devices — read-only JSON view *@ -
-
Devices
-
-

- Device list (host addresses, PLC family) — full list-editor coming in a follow-up phase. - Each entry: { "hostAddress": "...", "plcFamily": "Slc500" }. - PLC families: Slc500, MicroLogix, Plc5, LogixPccc. -

-
@_devicesJson
-
-
+ @* Devices *@ + + + Host addressPLC familyDevice name + + + @d.HostAddress@d.PlcFamily + @(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName) + + +
+
+
+
+
+
+
+
+
+
- @* Tags — read-only JSON view *@ -
-
Tags
-
-

- Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON. - Each tag has a PCCC file address (e.g. N7:0, F8:0, B3:0/0). -

-
@_tagsJson
-
-
+ @* Tags *@ + + + NameDeviceAddressTypeWritable + + + @t.Name@t.DeviceHostAddress + @t.Address@t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
@@ -171,11 +206,9 @@ else private void OnAddressPicked(string address) => _pickedAddress = address; - // Collections are preserved through round-trip and shown as read-only JSON. - private IReadOnlyList _devices = []; - private IReadOnlyList _tags = []; - private string _devicesJson = "[]"; - private string _tagsJson = "[]"; + // Held separately because Devices/Tags are collections — edited via the CollectionEditor modal. + private List _devices = []; + private List _tags = []; protected override async Task OnInitializedAsync() { @@ -215,12 +248,10 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; - _devices = opts.Devices; - _tags = opts.Tags; + _devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList(); + _tags = opts.Tags.Select(AbLegacyTagRow.FromDefinition).ToList(); } } - _devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts); - _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); _loaded = true; } @@ -229,7 +260,11 @@ else _busy = true; _error = null; try { - var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); + var configJson = System.Text.Json.JsonSerializer.Serialize( + _form.ToOptions( + _devices.Select(r => r.ToDefinition()).ToList(), + _tags.Select(r => r.ToDefinition()).ToList()), + _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) { @@ -300,7 +335,11 @@ else } private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); + => System.Text.Json.JsonSerializer.Serialize( + _form.ToOptions( + _devices.Select(r => r.ToDefinition()).ToList(), + _tags.Select(r => r.ToDefinition()).ToList()), + _jsonOpts); private static AbLegacyDriverOptions? TryDeserialize(string json) { @@ -308,6 +347,91 @@ else catch { return null; } } + // Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record. + public sealed class AbLegacyDeviceRow + { + public string HostAddress { get; set; } = ""; + public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500; + public string? DeviceName { get; set; } + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // across a load→save. + private AbLegacyDeviceOptions? _source; + + public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new() + { + HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName, + _source = d, + }; + + public AbLegacyDeviceOptions ToDefinition() + { + var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily); + return baseDef with + { + HostAddress = HostAddress.Trim(), + PlcFamily = PlcFamily, + DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(), + }; + } + + public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required."; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase)) + return $"Duplicate device host address '{row.HostAddress}'."; + return null; + } + } + + // Mutable VM for the modal editor — AbLegacyTagDefinition is an immutable record. + public sealed class AbLegacyTagRow + { + public string Name { get; set; } = ""; + public string DeviceHostAddress { get; set; } = ""; + public string Address { get; set; } = ""; + public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int; + public bool Writable { get; set; } = true; + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // (WriteIdempotent) across a load→save. + private AbLegacyTagDefinition? _source; + + public AbLegacyTagRow Clone() => (AbLegacyTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static AbLegacyTagRow FromDefinition(AbLegacyTagDefinition d) => new() + { + Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address, + DataType = d.DataType, Writable = d.Writable, + _source = d, + }; + + public AbLegacyTagDefinition ToDefinition() + { + var baseDef = _source ?? new AbLegacyTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType); + return baseDef with + { + Name = Name.Trim(), + DeviceHostAddress = DeviceHostAddress.Trim(), + Address = Address.Trim(), + DataType = DataType, + Writable = Writable, + }; + } + + public static string? ValidateRow(AbLegacyTagRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required."; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase)) + return $"Duplicate tag name '{row.Name}'."; + return null; + } + } + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. // Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). public sealed class FormModel diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index cd39dc55..1df73121 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; @@ -15,6 +16,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests WriteIndented = false, }; + private static readonly JsonSerializerOptions TestJsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + [Fact] public void RoundTrip_PreservesKnownFields() { @@ -78,4 +85,102 @@ public sealed class AbLegacyDriverPageFormSerializationTests back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(10); } + + [Fact] + public void DeviceRow_round_trips_through_definition() + { + var row = new AbLegacyDriverPage.AbLegacyDeviceRow + { + HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A", + }; + var def = row.ToDefinition(); + var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def); + + back.HostAddress.ShouldBe("10.0.0.10"); + back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix); + back.DeviceName.ShouldBe("PLC-A"); + } + + [Fact] + public void DeviceRow_preserves_unedited_fields() + { + var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A"); + var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original); + row.HostAddress = "10.0.0.20"; + + var back = row.ToDefinition(); + back.HostAddress.ShouldBe("10.0.0.20"); + back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5); + back.DeviceName.ShouldBe("PLC-A"); + } + + [Fact] + public void TagRow_round_trips_through_definition() + { + var row = new AbLegacyDriverPage.AbLegacyTagRow + { + Name = "Level", DeviceHostAddress = "10.0.0.10", Address = "N7:5", + DataType = AbLegacyDataType.Int, Writable = true, + }; + var def = row.ToDefinition(); + var back = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(def); + + back.Name.ShouldBe("Level"); + back.DeviceHostAddress.ShouldBe("10.0.0.10"); + back.Address.ShouldBe("N7:5"); + back.DataType.ShouldBe(AbLegacyDataType.Int); + back.Writable.ShouldBeTrue(); + } + + [Fact] + public void TagRow_preserves_unedited_fields() + { + var original = new AbLegacyTagDefinition( + "Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int, + Writable: true, WriteIdempotent: true); + var row = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(original); + row.Name = "Renamed"; + + var back = row.ToDefinition(); + back.Name.ShouldBe("Renamed"); + back.WriteIdempotent.ShouldBeTrue(); + } + + [Fact] + public void ValidateDeviceRow_rejects_duplicate_host() + { + var rows = new List { new() { HostAddress = "10.0.0.10" } }; + AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void ValidateTagRow_rejects_duplicate_name() + { + var rows = new List { new() { Name = "Level" } }; + AbLegacyDriverPage.AbLegacyTagRow.ValidateRow(new() { Name = "Level" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void Device_and_tag_lists_survive_options_serialize_round_trip() + { + var devices = new List + { + new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"), + new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"), + }; + var tags = new List + { + new("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int), + new("Pump", "10.0.0.11", "B3:0/0", AbLegacyDataType.Bit), + }; + var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices, tags); + var json = JsonSerializer.Serialize(opts, TestJsonOpts); + var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; + back.Devices.Count.ShouldBe(2); + back.Devices[0].HostAddress.ShouldBe("10.0.0.10"); + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("Level"); + } }